22 Commits

Author SHA1 Message Date
SpecialX
1f901c5b20 feat(data-ana): implement complete CDC pipeline MySQL to ClickHouse
Debezium + Kafka + aiokafka consumer with table routing.

E2E verified: MySQL INSERT to ClickHouse upsert.
2026-07-09 13:02:59 +08:00
SpecialX
958b17c9d8 feat(infra): p6 hardening - metrics collection and registry mirror
- 4 services add collectDefaultMetrics for process metrics
- docker-compose.yml images prefixed with docker.m.daocloud.io
- Prometheus v0.51.0 (nonexistent) fixed to v2.51.0
- Grafana port remapped 3000 to 3030 to avoid teacher-portal conflict
- ClickHouse edu_analytics database initialized
- known-issues.md documents 7 new P6 scenario-to-rule mappings
2026-07-09 12:29:36 +08:00
SpecialX
566060fade feat(infra): p6 hardening - observability and deploy compose
- 5 NestJS services add /metrics endpoint via app.getHttpAdapter()
- prometheus.yml scales to 8 services with rule_files and alertmanager
- monitoring compose replaces blackbox with Loki+Promtail
- Grafana datasource adds Loki
- docker-compose.deploy.yml scales to 11 services
- deploy.env.example completes Neo4j/ES/ClickHouse/LLM vars
- teacher-bff adds health.controller
- CI removes continue-on-error on lint step
- teacher-portal lint script changed to eslint src
2026-07-09 10:21:06 +08:00
SpecialX
3ca654619f chore(infra): 配置ESLint 9 flat config并恢复lint-staged集成
- 新增 eslint.config.js(ESLint 9 flat config 格式)
- 安装 @eslint/js + typescript-eslint + eslint-config-prettier
- 6 个 TS 服务 lint 脚本:eslint src --ext .ts → eslint src
- lint-staged 恢复 eslint --fix
- .gitignore 忽略 docker-compose.minimal.override.yml
- known-issues.md 新增 P6 硬化条目
2026-07-09 09:14:44 +08:00
SpecialX
a70a74207e feat(ai): 完善AI网关服务并添加LLM降级模式
config.py 加openai_api_key/base_url/dev_mode

新建llm_client.py httpx异步调OpenAI REST API

main.py 业务路由加/ai前缀+降级模式+readyz端点

Gateway添加/notifications和/ai路由

docs: known-issues记录P5三服务经验
2026-07-09 09:09:27 +08:00
SpecialX
dfb6d2bfc1 feat(push-gateway): 修复WebSocket并发写竞争并添加DEV_MODE鉴权
hub.go 重写用send chan+单写协程模式避免并发写竞争

handler.go 加DEV_MODE dev-token支持+broadcast端点

config.go 加DevMode/RedisURL字段
2026-07-09 09:09:13 +08:00
SpecialX
416e1bc0b2 feat(msg): 修复通知服务并添加ES降级与Push Gateway推送
database.ts 导出db常量替代getDb()函数

env.ts JWT_SECRET/ES_URL改optional加DEV_MODE/PUSH_GATEWAY_URL

elasticsearch.ts ES降级: ES_URL未设置时esClient=null

notifications.service.ts 加createBatch+分页查询+Push Gateway推送调用

新建msg-init.sql创建2张表
2026-07-09 09:08:57 +08:00
SpecialX
421edd8a41 feat(data-ana): 完善学情诊断服务并添加ClickHouse降级模式
config.py ClickHouse连接改可选+加DEV_MODE/kafka_brokers

clickhouse_client.py 降级模式: host为空时返回None

main.py 端点先查ClickHouse降级返回骨架数据+新增errorbook

新增clickhouse-init.sql创建宽表和错题表

Gateway添加/analytics路由
2026-07-09 08:58:39 +08:00
SpecialX
5f18821302 feat(api-gateway): 添加content服务代理路由
main.go添加textbooks/chapters/knowledge-points/questions四组路由

config.go新增ContentServiceURL字段

docs: known-issues记录P4 content端到端验证经验
2026-07-09 08:52:30 +08:00
SpecialX
921fe82771 feat(content): 修复服务并添加chapters/knowledge-points/questions模块
- database.ts 导出db常量替代getDb()函数

- env.ts JWT_SECRET/ES_URL/NEO4J_URL改optional加DEV_MODE

- neo4j.ts driver惰性创建+try/catch+connectionTimeout:3000

- health/lifecycle改用Drizzle原生查询

- textbooks.schema修复integer到int+导出NewTextbook类型

- 新建chapters/knowledge-points/questions三模块CRUD

- knowledge-points含Neo4j前置依赖图非阻塞查询

- content-init.sql创建4张表

端到端验证: textbooks/chapters/knowledge-points/questions全CRUD通过
2026-07-09 08:52:15 +08:00
SpecialX
033c083619 feat(teacher-portal): 添加考试作业成绩查询页面
新增 3 个页面位于 (app) 路由组:
  /exams - 按班级查询考试列表
  /homework - 按班级查询作业列表
  /grades - 按考试查询成绩列表

通过 BFF 聚合端点获取 core-edu 数据
遵循纸面设计风格: serif 标题/mono ID/设计令牌
2026-07-09 08:07:02 +08:00
SpecialX
6215f4e21f feat(teacher-bff): 添加考试作业成绩聚合端点
env.ts 新增 CoreEduServiceUrl
teacher.service 新增 listExamsByClass/listHomeworkByClass/listGradesByExam 三个聚合方法
teacher.controller 暴露 3 个 GET 端点:
  /teacher/classes/:classId/exams
  /teacher/classes/:classId/homework
  /teacher/exams/:examId/grades

验证: 3 端点全部 200 返回 core-edu 数据
2026-07-09 08:05:24 +08:00
SpecialX
beb204c00f docs(docs): 记录 P3 core-edu 端到端验证经验与场景映射
core-edu 段新增 6 行: datetime 转换/Kafka 非阻塞/相对 import/身份头/健康检查/Outbox 验证
api-gateway 段新增 3 行: 路由注册位置/多服务路由扩展/DEV_MODE 环境变量
经验日志追加 2026-07-09 P3 条目
2026-07-09 08:04:00 +08:00
SpecialX
4f539b50dd feat(api-gateway): 添加 core-edu 服务代理路由
main.go 新增 exams/homework/grades 三组路由(无尾斜杠+通配符)
config.go 新增 CoreEduServiceURL 字段默认 localhost:3004
删除死代码 internal/routing/routing.go(从未被 main 引用)

验证: GET /exams/class/:id 200, POST /exams 201, 链路全通
2026-07-09 08:02:39 +08:00
SpecialX
4533da6484 feat(core-edu): 修复服务启动并打通考试作业成绩端到端链路
database.ts 导出 db 常量替代 getDb()
env.ts JWT_SECRET 改 optional 并新增 DEV_MODE
kafka.ts connectKafka 加 try/catch 不阻塞启动
main.ts 去全局前缀 connectKafka 改非阻塞
app.module 移除未用模块加 HealthModule
controller 路由去前缀去 UseGuards 从 x-user-id 读身份
service datetime ISO 字符串转 Date 修复 drizzle 错误
修正相对 import 路径
health/lifecycle 改用 Drizzle 原生查询
新增 core-edu-init.sql 初始化 4 张表

端到端验证: exams/homework/grades 全部 201/200
Outbox 事件正确写入 core_edu_outbox 表
2026-07-09 08:02:22 +08:00
SpecialX
2c7afe59ef feat(teacher-portal): 实现登录页侧边栏路由组与真实JWT集成
- lib/auth.ts: localStorage token 存储 + login/logout

- app/login: 登录表单页

- components/AppShell: 视口驱动侧边栏 + 路由保护

- app/(app)/layout: 路由组布局套 AppShell

- app/(app)/dashboard: 聚合统计卡片页

- app/(app)/classes: 迁移 CRUD 改用真实 JWT

- app/page.tsx: 根路径重定向

- known-issues.md: 记录 P2 经验
2026-07-09 00:58:42 +08:00
SpecialX
b2c2f6e567 feat(teacher-bff): 添加视口聚合端点 + 修复身份头读取
- 新增 GET /teacher/viewports 聚合 IAM 视口配置
- 修复 controller 从 x-user-id header 读取身份(替代 AuthenticatedRequest)
- 添加 zod + @types/express 依赖
2026-07-09 00:49:24 +08:00
SpecialX
a2f0ca26ae feat(iam): 实现 RBAC + 视口配置 + DataScope
- 新增 iam_role_viewports 表(4 层视口模型:L1 导航/L2 路由/L3 组件/L4 数据)
- iam_users 添加 data_scope 字段(6 级:self/class/grade/school/district/all)
- JWT payload 包含 dataScope,注册时自动分配 teacher 角色
- 新增 getEffectivePermissions(多角色权限去重)
- 新增 getUserViewports(按权限过滤视口)
- 新增 RbacController:GET /iam/viewports, /iam/permissions/effective, /iam/roles, /iam/permissions
- 添加 7 个权限点 + 12 条角色权限映射 + 7 个视口种子数据
2026-07-09 00:35:34 +08:00
SpecialX
5759b09c9f feat(api-gateway): 添加公开路径白名单
register/login/refresh 无需 JWT 即可通过 Gateway,修复之前无 token 无法注册登录的死锁问题。
2026-07-09 00:30:35 +08:00
SpecialX
d92cdda727 docs(docs): 记录 P1 端到端验证经验与 IAM 场景映射
在 2.3 iam 分区补充 5 条场景映射(ESM DI/Drizzle API/健康检查/Gateway 身份传递/DEV_MODE 登录);工作经验日志追加 P1 端到端验证完整记录。
2026-07-09 00:26:28 +08:00
SpecialX
adea22b133 chore(iam): 添加 IAM 数据库初始化脚本
包含 6 张 IAM 表结构与 teacher/admin 默认角色种子数据,用于 P1 端到端验证。
2026-07-09 00:25:58 +08:00
SpecialX
f658571726 fix(iam): 修复 TS 编译错误、ESM 依赖注入与身份头读取
P1 端到端验证中发现 IAM 服务存在 14 个 TS 编译错误与运行时 DI 失败:

- 移除 typeorm/ioredis/kafkajs 依赖(IAM 用 Drizzle)
- health.controller.ts 改用 db.execute(sql SELECT 1) 校验连接
- lifecycle.service.ts 简化为只关闭 Drizzle 连接池
- Drizzle API 修正:r.roles -> r.iam_roles,.in() -> inArray()
- ESM 模式下 DI 必须显式 @Inject(IamRepository)(参考 classes 黄金模板)
- iam.controller.ts 直接读 req.headers[x-user-id],不依赖未注册的 AuthMiddleware
- health.module.ts 补 .js 后缀
- package.json 补 @types/express

验证:register -> JWT -> Gateway /iam/me 200 -> /classes CRUD 200
2026-07-09 00:25:37 +08:00
129 changed files with 7264 additions and 2182 deletions

View File

@@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
container: node:22-alpine
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.commit_sha || github.ref }}
@@ -45,14 +45,13 @@ jobs:
- name: Lint
run: pnpm -r run lint
continue-on-error: true # P1: ESLint 9 flat config 迁移未完成
- name: Typecheck
run: pnpm -r run typecheck
- name: Test
run: pnpm -r run test
continue-on-error: true # P1: 部分服务无 test 脚本
continue-on-error: true # P6: 部分服务无 test 脚本,待补全
- name: Build
run: pnpm -r run build

1
.gitignore vendored
View File

@@ -59,6 +59,7 @@ coverage/
# Docker
docker-compose.override.yml
docker-compose.minimal.override.yml
# Temp
tmp/

View File

@@ -6,7 +6,7 @@
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -19,6 +19,7 @@
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^9.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"

View File

@@ -0,0 +1,293 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getToken } from "@/lib/auth";
interface ClassItem {
id: string;
name: string;
gradeId: string;
description?: string;
createdAt: number;
updatedAt: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function ClassesPage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
const [name, setName] = useState("");
const [gradeId, setGradeId] = useState(
"550e8400-e29b-41d4-a716-446655440000",
);
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const authHeaders = (): Record<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const fetchClasses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/v1/classes", {
headers: authHeaders(),
});
const json: ApiResponse<ClassItem[]> = await res.json();
if (json.success && json.data) {
setClasses(json.data);
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
const res = await fetch("/api/v1/classes", {
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify({ name, gradeId, description }),
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Create failed");
return;
}
setName("");
setDescription("");
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/v1/classes/${id}`, {
method: "DELETE",
headers: authHeaders(),
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Delete failed");
return;
}
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
classes CRUD · JWT
</p>
</header>
<div className="rule-thin mb-8" />
<div className="grid grid-cols-12 gap-8">
<aside className="col-span-4">
<h2
className="text-xl mb-4"
style={{ fontFamily: "var(--font-serif)" }}
>
</h2>
<div className="rule-thin mb-4" />
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
placeholder="如:高三(1)班"
required
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
ID
</label>
<input
type="text"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b resize-none"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
rows={3}
/>
</div>
<button
type="submit"
className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90"
style={{ background: "var(--color-accent)", borderRadius: "6px" }}
>
</button>
</form>
</aside>
<section className="col-span-8">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl" style={{ fontFamily: "var(--font-serif)" }}>
<span
className="ml-2 text-sm font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{classes.length}
</span>
</h2>
<button
onClick={fetchClasses}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-accent)" }}
>
</button>
</div>
<div className="rule-thin mb-6" />
{error && (
<div
className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p
className="text-sm px-3"
style={{ color: "var(--color-accent)" }}
>
{error}
</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : classes.length === 0 ? (
<p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-0">
{classes.map((cls) => (
<li
key={cls.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-7">
<h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{cls.name}
</h3>
{cls.description && (
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.description}
</p>
)}
</div>
<div
className="col-span-3 text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.id.slice(0, 8)}...
</div>
<div className="col-span-2 text-right">
<button
onClick={() => handleDelete(cls.id)}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-ink-muted)" }}
>
</button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useEffect, useState } from "react";
import { getToken, getUser, type UserInfo } from "@/lib/auth";
interface DashboardData {
user: { success: boolean; data?: { user: UserInfo } };
classes: { success: boolean; data?: unknown[] };
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const user = getUser();
useEffect(() => {
const token = getToken();
if (!token) return;
(async () => {
try {
const res = await fetch("/api/v1/teacher/dashboard", {
headers: { Authorization: `Bearer ${token}` },
});
const json = await res.json();
if (json.success) {
setData(json.data);
} else {
setError(json.error?.message || "加载失败");
}
} catch (e) {
setError(e instanceof Error ? e.message : "网络错误");
} finally {
setLoading(false);
}
})();
}, []);
const classesCount = Array.isArray(data?.classes?.data)
? data!.classes.data.length
: 0;
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1
className="text-3xl"
style={{ fontFamily: "var(--font-serif)", color: "var(--color-ink)" }}
>
{user?.name || "老师"}
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{user?.email} · {user?.roles.join(", ") || "无"} ·
{user?.dataScope || "-"}
</p>
</header>
<div className="rule-thin mb-8" />
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : error ? (
<div
className="mark-left py-2 mb-4"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm px-3" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div>
) : (
<section className="grid grid-cols-3 gap-6">
<div
className="p-6 border"
style={{ borderColor: "var(--color-rule)" }}
>
<p
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="mt-3 text-4xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{classesCount}
</p>
</div>
<div
className="p-6 border"
style={{ borderColor: "var(--color-rule)" }}
>
<p
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="mt-3 text-4xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{user?.permissions.length ?? 0}
</p>
</div>
<div
className="p-6 border"
style={{ borderColor: "var(--color-rule)" }}
>
<p
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="mt-3 text-2xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{user?.dataScope || "-"}
</p>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getToken } from "@/lib/auth";
interface ExamItem {
id: string;
classId: string;
title: string;
description?: string;
examDate: string;
duration: string;
totalScore: string;
status: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function ExamsPage() {
const [exams, setExams] = useState<ExamItem[]>([]);
const [loading, setLoading] = useState(false);
const [classId, setClassId] = useState(
"00000000-0000-0000-0000-000000000001",
);
const [error, setError] = useState<string | null>(null);
const authHeaders = (): Record<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const fetchExams = useCallback(async () => {
if (!classId.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/v1/teacher/classes/${encodeURIComponent(classId)}/exams`,
{ headers: authHeaders() },
);
const json: ApiResponse<ExamItem[]> = await res.json();
if (json.success && json.data) {
setExams(json.data);
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
}, [classId]);
useEffect(() => {
fetchExams();
}, [fetchExams]);
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
core-edu · BFF
</p>
</header>
<div className="rule-thin mb-8" />
<div className="mb-6 flex items-baseline gap-3">
<label
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
ID
</label>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="flex-1 max-w-md px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{ borderColor: "var(--color-rule)" }}
placeholder="输入班级 UUID"
/>
<button
onClick={fetchExams}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-accent)" }}
>
</button>
</div>
{error && (
<div
className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm px-3" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : exams.length === 0 ? (
<p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-0">
{exams.map((exam) => (
<li
key={exam.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-7">
<h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{exam.title}
</h3>
{exam.description && (
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{exam.description}
</p>
)}
<p
className="mt-1 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
: {new Date(exam.examDate).toLocaleString("zh-CN")}
{" · "}
{exam.duration}
{" · "}
{exam.totalScore}
</p>
</div>
<div
className="col-span-3 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
: {exam.status}
</div>
<div
className="col-span-2 text-right text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{exam.id.slice(0, 8)}...
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getToken } from "@/lib/auth";
interface GradeItem {
id: string;
studentId: string;
examId: string | null;
homeworkId: string | null;
score: string;
feedback?: string;
gradedBy: string;
createdAt: string;
updatedAt: string;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function GradesPage() {
const [items, setItems] = useState<GradeItem[]>([]);
const [loading, setLoading] = useState(false);
const [examId, setExamId] = useState("");
const [error, setError] = useState<string | null>(null);
const authHeaders = (): Record<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const fetchGrades = useCallback(async () => {
if (!examId.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/v1/teacher/exams/${encodeURIComponent(examId)}/grades`,
{ headers: authHeaders() },
);
const json: ApiResponse<GradeItem[]> = await res.json();
if (json.success && json.data) {
setItems(json.data);
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
}, [examId]);
useEffect(() => {
fetchGrades();
}, [fetchGrades]);
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
core-edu · BFF
</p>
</header>
<div className="rule-thin mb-8" />
<div className="mb-6 flex items-baseline gap-3">
<label
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
ID
</label>
<input
type="text"
value={examId}
onChange={(e) => setExamId(e.target.value)}
className="flex-1 max-w-md px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{ borderColor: "var(--color-rule)" }}
placeholder="输入考试 UUID"
/>
<button
onClick={fetchGrades}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-accent)" }}
>
</button>
</div>
{error && (
<div
className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm px-3" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : items.length === 0 ? (
<p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
{examId.trim() ? "该考试下暂无成绩" : "请输入考试 ID 查询成绩"}
</p>
) : (
<ul className="space-y-0">
{items.map((g) => (
<li
key={g.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-6">
<h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
: {g.studentId}
</h3>
{g.feedback && (
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
: {g.feedback}
</p>
)}
</div>
<div
className="col-span-2 text-2xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-accent)",
}}
>
{g.score}
</div>
<div
className="col-span-2 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
: {g.gradedBy}
</div>
<div
className="col-span-2 text-right text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{g.id.slice(0, 8)}...
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getToken } from "@/lib/auth";
interface HomeworkItem {
id: string;
classId: string;
title: string;
description?: string;
dueDate: string;
status: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function HomeworkPage() {
const [items, setItems] = useState<HomeworkItem[]>([]);
const [loading, setLoading] = useState(false);
const [classId, setClassId] = useState(
"00000000-0000-0000-0000-000000000001",
);
const [error, setError] = useState<string | null>(null);
const authHeaders = (): Record<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const fetchHomework = useCallback(async () => {
if (!classId.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/v1/teacher/classes/${encodeURIComponent(classId)}/homework`,
{ headers: authHeaders() },
);
const json: ApiResponse<HomeworkItem[]> = await res.json();
if (json.success && json.data) {
setItems(json.data);
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
}, [classId]);
useEffect(() => {
fetchHomework();
}, [fetchHomework]);
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
core-edu · BFF
</p>
</header>
<div className="rule-thin mb-8" />
<div className="mb-6 flex items-baseline gap-3">
<label
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
ID
</label>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="flex-1 max-w-md px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{ borderColor: "var(--color-rule)" }}
placeholder="输入班级 UUID"
/>
<button
onClick={fetchHomework}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-accent)" }}
>
</button>
</div>
{error && (
<div
className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm px-3" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : items.length === 0 ? (
<p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-0">
{items.map((hw) => (
<li
key={hw.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-7">
<h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{hw.title}
</h3>
{hw.description && (
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{hw.description}
</p>
)}
<p
className="mt-1 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
: {new Date(hw.dueDate).toLocaleString("zh-CN")}
</p>
</div>
<div
className="col-span-3 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
: {hw.status}
</div>
<div
className="col-span-2 text-right text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{hw.id.slice(0, 8)}...
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import AppShell from "@/components/AppShell";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { login, isAuthenticated } from "@/lib/auth";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("teacher2@edu.test");
const [password, setPassword] = useState("Teacher@123");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated()) {
router.replace("/dashboard");
}
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
await login(email, password);
router.replace("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
};
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--bg-paper)" }}
>
<div className="w-full max-w-sm px-8">
<div className="text-center mb-8">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
Edu
</h1>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<div className="rule-thin mb-6" />
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
required
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
required
/>
</div>
{error && (
<div
className="mark-left py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p
className="text-sm px-3"
style={{ color: "var(--color-accent)" }}
>
{error}
</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90 disabled:opacity-50"
style={{ background: "var(--color-accent)", borderRadius: "6px" }}
>
{loading ? "登录中..." : "登录"}
</button>
</form>
<p
className="mt-6 text-center text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
P2 · JWT + RBAC +
</p>
</div>
</div>
);
}

View File

@@ -1,289 +1,13 @@
"use client";
import { useState, useEffect, useCallback } from "react";
interface ClassItem {
id: string;
name: string;
gradeId: string;
description?: string;
createdAt: number;
updatedAt: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
const [name, setName] = useState("");
const [gradeId, setGradeId] = useState(
"550e8400-e29b-41d4-a716-446655440000",
);
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const fetchClasses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/v1/classes", {
headers: { Authorization: "Bearer dev-token" },
});
const json: ApiResponse<ClassItem[]> = await res.json();
if (json.success && json.data) {
setClasses(json.data);
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
}, []);
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated } from "@/lib/auth";
export default function Home() {
const router = useRouter();
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
const res = await fetch("/api/v1/classes", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer dev-token",
},
body: JSON.stringify({ name, gradeId, description }),
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Create failed");
return;
}
setName("");
setDescription("");
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/v1/classes/${id}`, {
method: "DELETE",
headers: { Authorization: "Bearer dev-token" },
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Delete failed");
return;
}
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
return (
<div className="min-h-screen" style={{ background: "var(--bg-paper)" }}>
<header className="border-b" style={{ borderColor: "var(--color-rule)" }}>
<div className="max-w-6xl mx-auto px-8 py-6">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1>
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
P1 - classes CRUD
</p>
</div>
</header>
<main className="max-w-6xl mx-auto px-8 py-8 grid grid-cols-12 gap-8">
{/* 左侧:创建表单 */}
<aside className="col-span-4">
<h2
className="text-xl mb-4"
style={{ fontFamily: "var(--font-serif)" }}
>
</h2>
<div className="rule-thin mb-4" />
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
placeholder="如:高三(1)班"
required
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
ID
</label>
<input
type="text"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b resize-none"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
rows={3}
/>
</div>
<button
type="submit"
className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90"
style={{ background: "var(--color-accent)", borderRadius: "6px" }}
>
</button>
</form>
</aside>
{/* 中间:班级列表(纸面)*/}
<section className="col-span-8">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl" style={{ fontFamily: "var(--font-serif)" }}>
<span
className="ml-2 text-sm font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{classes.length}
</span>
</h2>
<button
onClick={fetchClasses}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-accent)" }}
>
</button>
</div>
<div className="rule-thin mb-6" />
{error && (
<div
className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : classes.length === 0 ? (
<p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-0">
{classes.map((cls) => (
<li
key={cls.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-7">
<h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{cls.name}
</h3>
{cls.description && (
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.description}
</p>
)}
</div>
<div
className="col-span-3 text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.id.slice(0, 8)}...
</div>
<div className="col-span-2 text-right">
<button
onClick={() => handleDelete(cls.id)}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-ink-muted)" }}
>
</button>
</div>
</li>
))}
</ul>
)}
</section>
</main>
</div>
);
router.replace(isAuthenticated() ? "/dashboard" : "/login");
}, [router]);
return null;
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { getToken, getUser, logout } from "@/lib/auth";
interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: string;
requiredPermission: string | null;
}
export default function AppShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [viewports, setViewports] = useState<ViewportItem[]>([]);
const [loading, setLoading] = useState(true);
const [mounted, setMounted] = useState(false);
const fetchViewports = useCallback(async () => {
const token = getToken();
if (!token) {
router.replace("/login");
return;
}
try {
const res = await fetch("/api/v1/teacher/viewports", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
logout();
return;
}
const json = await res.json();
if (json.success && json.data) {
setViewports(json.data);
}
} catch {
// 网络错误,保持空视口
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
setMounted(true);
const token = getToken();
if (!token) {
router.replace("/login");
return;
}
fetchViewports();
}, [router, fetchViewports]);
// 防止 SSR 闪烁
if (!mounted) return null;
const user = getUser();
return (
<div
className="min-h-screen flex"
style={{ background: "var(--bg-paper)" }}
>
{/* 左侧栏:导航树 */}
<aside
className="w-56 flex-shrink-0 border-r relative flex flex-col"
style={{
borderColor: "var(--color-rule)",
background: "var(--bg-paper)",
}}
>
<div className="px-6 py-6">
<h1
className="text-xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
Edu
</h1>
<p
className="text-xs mt-1"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<div className="rule-thin mx-6" />
<nav className="mt-4 px-3">
{loading ? (
<p
className="text-xs px-3 py-2"
style={{ color: "var(--color-ink-muted)" }}
>
...
</p>
) : (
viewports.map((vp) => {
const active = pathname === vp.route;
return (
<Link
key={vp.key}
href={vp.route}
className="block px-3 py-2 text-sm transition-colors"
style={{
color: active ? "var(--color-accent)" : "var(--color-ink)",
borderLeft: active
? "2px solid var(--color-accent)"
: "2px solid transparent",
fontFamily: active
? "var(--font-serif)"
: "var(--font-inter)",
}}
>
{vp.label}
</Link>
);
})
)}
</nav>
{/* 底部:用户信息 + 登出 */}
<div
className="mt-auto px-6 py-4 border-t"
style={{ borderColor: "var(--color-rule)" }}
>
{user && (
<div className="mb-2">
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{user.name}
</p>
<p
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{user.roles.join(", ") || "无角色"}
</p>
</div>
)}
<button
onClick={logout}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-ink-muted)" }}
>
退
</button>
</div>
</aside>
{/* 中间:内容区(纸面) */}
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,77 @@
// 认证工具token 存储 + 路由保护
const TOKEN_KEY = "edu_access_token";
const USER_KEY = "edu_user_info";
export interface UserInfo {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: string;
}
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getUser(): UserInfo | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as UserInfo;
} catch {
return null;
}
}
export function setUser(user: UserInfo): void {
if (typeof window === "undefined") return;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function isAuthenticated(): boolean {
return getToken() !== null;
}
// 调用 Gateway 登录接口
export async function login(
email: string,
password: string,
): Promise<{ user: UserInfo; token: string }> {
const res = await fetch("/api/v1/iam/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const json = await res.json();
if (!json.success) {
throw new Error(json.error?.message || "登录失败");
}
const user = json.data.user as UserInfo;
const token = json.data.tokens.accessToken as string;
setToken(token);
setUser(user);
return { user, token };
}
export function logout(): void {
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
}

View File

@@ -10,30 +10,37 @@
### 1.1 多语言 monorepo 配置
| 场景 | 技术/规则 |
| ------------------------ | ------------------------------------------------------------------------------------------ |
| 多语言 workspace | pnpm workspaceTS+ go.workGo+ pyproject.toml/uv workspacePython三套并存 |
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
| pnpm-workspace.yaml | 仅声明 TS 包路径packages/_、services/classes、bff/_、apps/_、scripts/_Go/Python 不入 |
| go.work | 列出所有 Go 服务模块services/api-gateway、services/push-gateway |
| pyproject.toml | uv workspace members 列 Python 服务services/data-ana、services/ai-gateway |
| 跨语言共享类型 | protobuf 生成三端代码TS/Go/Python单一契约源 |
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
| husky + commitlint | pre-commit 跑 eslint+prettiercommit-msg 校验 Conventional Commits |
| .editorconfig 多语言缩进 | Go 用 tabPython 用 4 空格TS/默认用 2 空格 |
| 场景 | 技术/规则 |
| ------------------------ | ------------------------------------------------------------------------------------------------------------- |
| 多语言 workspace | pnpm workspaceTS+ go.workGo+ pyproject.toml/uv workspacePython三套并存 |
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
| pnpm-workspace.yaml | 仅声明 TS 包路径packages/_、services/classes、bff/_、apps/_、scripts/_Go/Python 不入 |
| go.work | 列出所有 Go 服务模块services/api-gateway、services/push-gateway |
| pyproject.toml | uv workspace members 列 Python 服务services/data-ana、services/ai-gateway |
| 跨语言共享类型 | protobuf 生成三端代码TS/Go/Python单一契约源 |
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
| husky + commitlint | pre-commit 跑 eslint+prettiercommit-msg 校验 Conventional Commits |
| .editorconfig 多语言缩进 | Go 用 tabPython 用 4 空格TS/默认用 2 空格 |
| ESLint 9 flat config | P6 硬化:创建 `eslint.config.js`flat configlint 脚本去掉 `--ext .ts`lint-staged 恢复 `eslint --fix` |
| next lint 交互式初始化 | teacher-portal 无 `.eslintrc.json``next lint` 触发 Strict/Base 选择提示CI 中需预置配置或改 `eslint src` |
### 1.2 Docker Compose 基础设施
| 场景 | 技术/规则 |
| ---------------- | ----------------------------------------------------------------------------------------------- |
| 日常开发启动 | 用 `docker-compose.minimal.yml` 仅起 MySQL+Redis |
| 全量启动内存不足 | 按 `profiles` 分阶段启用full/kafka/cdc/analytics/graph/search/config/observability |
| 每服务 mem_limit | 避免单服务吃满内存MySQL 512m、Redis 128m、Kafka 512m、ClickHouse 1g |
| 按阶段启用容器 | P1 仅 MySQL+RedisP3 加 Kafka+ZookeeperP4 加 Debezium+CH+Neo4jP5 加 ESP6 加 Consul+Istio |
| MySQL 初始化 | `init-sql/01-init.sql` 挂载到 `/docker-entrypoint-initdb.d:ro` |
| healthcheck | MySQL 用 `mysqladmin ping`Redis 用 `redis-cli ping` |
| Windows 下卷挂载 | init-sql 用绝对路径或确保相对路径正确 |
| 容器名固定 | `container_name: edu-mysql` 便于服务连接配置 |
| 场景 | 技术/规则 |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 日常开发启动 | 用 `docker-compose.minimal.yml` 仅起 MySQL+Redis |
| 全量启动内存不足 | 按 `profiles` 分阶段启用full/kafka/cdc/analytics/graph/search/config/observability |
| 每服务 mem_limit | 避免单服务吃满内存MySQL 512m、Redis 128m、Kafka 512m、ClickHouse 1g |
| 按阶段启用容器 | P1 仅 MySQL+RedisP3 加 Kafka+ZookeeperP4 加 Debezium+CH+Neo4jP5 加 ESP6 加 Consul+Istio |
| MySQL 初始化 | `init-sql/01-init.sql` 挂载到 `/docker-entrypoint-initdb.d:ro` |
| healthcheck | MySQL 用 `mysqladmin ping`Redis 用 `redis-cli ping` |
| Windows 下卷挂载 | init-sql 用绝对路径或确保相对路径正确 |
| 容器名固定 | `container_name: edu-mysql` 便于服务连接配置 |
| Kafka 双 listener | INSIDE (kafka:29092) 容器间互访 + OUTSIDE (localhost:9092) 主机访问,避免 Debezium 拿到 localhost metadata 后切回连不上 |
| ClickHouse 远程访问 | 默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP |
| Debezium Connect 镜像源 | daocloud 禁用 debezium/*,用 `quay.io/debezium/connect:2.7` 替代 |
| Debezium 跨网络访问 MySQL | MySQL 容器在 edu-minimal_default 时,`docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达 |
| CDC 注册 connector | POST `:8083/connectors`,配置 `topic.prefix`/`database.include.list`/`schema.history.internal.kafka.topic` |
### 1.3 protobuf + buf 契约
@@ -84,15 +91,26 @@
### 1.6 可观测性OTel + Prometheus + Loki
| 场景 | 技术/规则 |
| --------------- | -------------------------------------------------------------------------------- |
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
| 三支柱 | Logspino/winston/zap+ Metricsprom-client+ TracesOTel SDK |
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
| P6 完整后端 | Loki日志+ Grafana仪表盘+ Jaegertrace+ Prometheusmetrics |
| Prometheus 指标 | `http_request_duration_seconds`Histogram+ `http_requests_total`Counter |
| 采样策略 | P1 全量 traceP6 引入采样率降低开销 |
| 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` |
| 场景 | 技术/规则 |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
| 三支柱 | Logspino/winston/zap+ Metricsprom-client+ TracesOTel SDK |
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
| P6 完整后端 | Loki日志+ Grafana仪表盘+ Jaegertrace+ Prometheusmetrics |
| Prometheus 指标 | `http_request_duration_seconds`Histogram+ `http_requests_total`Counter |
| 采样策略 | P1 全量 traceP6 引入采样率降低开销 |
| 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` |
| P6 /metrics 端点 | NestJS 用 `app.getHttpAdapter().get('/metrics', handler)`,不能用 `app.get()`(会被解析为 DI 容器 get |
| P6 prometheus.yml | 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身rule_files 引用 rules.ymlalerting 关联 alertmanager |
| P6 Grafana 数据源 | provisioning/datasources 同时声明 Prometheus默认和 Lokiuid=loki |
| P6 Promtail 采集 | docker_sd_configs + relabel_configs 仅采集 `edu-*` 前缀容器日志,避免无关日志 |
| P6 service label | static_configs.labels.service 给所有抓取目标打服务标签,告警规则按 service 聚合 |
| P6 collectDefaultMetrics | prom-client 的 `collectDefaultMetrics({ register })` 自动收集进程级指标CPU/内存/事件循环/GC无需业务埋点即可让 /metrics 有数据 |
| P6 Counter/Histogram 埋点缺失 | metrics.ts 定义了 Counter/Histogram 但 service/controller 未调用 `.inc()`/`.observe()`,需后续补 HTTP 中间件自动埋点或业务埋点 |
| P6 OTel instrumentations 缺失 | tracer.ts 只配置 traceExporter 未注册 auto-instrumentationsHttpInstrumentation/ExpressInstrumentation导致 Jaeger 收不到业务 trace需后续补 `@opentelemetry/auto-instrumentations` |
| P6 镜像源配置 | 国内 docker.io 被墙compose image 必须加 `docker.m.daocloud.io/` 前缀Elastic 官方镜像在 docker.elastic.co 不被墙 |
| P6 Grafana 端口冲突 | Grafana 默认 3000 与 teacher-portal Next.js dev 冲突,改映射为 3030:3000 |
| P6 compose --no-deps | mysql/redis 已在另一 compose 项目运行时,启动新服务用 `--no-deps` + 显式指定服务名,避免重建依赖容器 | |
### 1.7 微前端 Module Federation
@@ -151,18 +169,21 @@
### 2.1 api-gatewayGo
| 场景 | 技术/规则 |
| ---------------- | --------------------------------------------------------------------------------------- |
| P1 鉴权 | Gateway 内置 HS256 JWT`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 |
| P2 鉴权升级 | 改 RS256IAM 私钥签发Gateway 公钥校验,无需调 IAM |
| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` |
| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务 |
| 用户上下文注入 | `c.Request.Header.Set("x-user-id", claims.UserID)` 传递给下游 |
| 请求 ID | Gateway 生成或透传 `X-Request-ID``c.Set("request_id", ...)` + `c.Header(...)` |
| P1 不做 | 限流/熔断/灰度P6 硬化阶段实现) |
| 健康检查 | `GET /health` 无需鉴权 |
| 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` |
| 开发模式鉴权旁路 | `DEV_MODE=true` 时接受 `Bearer dev-token`,注入固定身份;生产必须 `false` |
| 场景 | 技术/规则 |
| ----------------- | ------------------------------------------------------------------------------------------------------------- |
| P1 鉴权 | Gateway 内置 HS256 JWT`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 |
| P2 鉴权升级 | 改 RS256IAM 私钥签发Gateway 公钥校验,无需调 IAM |
| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` |
| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务 |
| 用户上下文注入 | `c.Request.Header.Set("x-user-id", claims.UserID)` 传递给下游 |
| 请求 ID | Gateway 生成或透传 `X-Request-ID``c.Set("request_id", ...)` + `c.Header(...)` |
| P1 不做 | 限流/熔断/灰度P6 硬化阶段实现) |
| 健康检查 | `GET /health` 无需鉴权 |
| 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` |
| 开发模式鉴权旁路 | `DEV_MODE=true` 时接受 `Bearer dev-token`,注入固定身份;生产必须 `false` |
| 路由注册位置 | 真实路由在 `main.go` 的 `api.Group` 内注册,`internal/routing/routing.go` 若未被 main 引用即为死代码 |
| 多服务路由扩展 | 新增服务代理时在 main.go 注册两组路由:`Any("/x")` + `Any("/x/*path")`,与 classes 一致 |
| DEV_MODE 环境变量 | Go 不自动加载 .env`DEV_MODE` 必须在启动前 export 或写入系统环境变量,否则 DevMode=false 导致 dev-token 被拒 |
### 2.2 classesTS/NestJSP1 黄金模板)
@@ -185,76 +206,106 @@
### 2.3 iamTS/NestJSP2
| 场景 | 技术/规则 |
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
| 认证 | 登录/登出/JWT/2FARS256 非对称签名 |
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API |
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 |
| DataScope 解析 | 6 级数据范围,注入 JWT payload |
| JWT payload | `{ userId, roles, permissions(bitmap), dataScope, exp }` |
| Token TTL | access 15min / refresh 7dayrefresh 用 Redis 黑名单失效 |
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
| schema 表 | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers |
| 场景 | 技术/规则 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 认证 | 登录/登出/JWT/2FARS256 非对称签名 |
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API |
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 |
| DataScope 解析 | 6 级数据范围,注入 JWT payload |
| JWT payload | `{ userId, roles, permissions(bitmap), dataScope, exp }` |
| Token TTL | access 15min / refresh 7dayrefresh 用 Redis 黑名单失效 |
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
| schema 表 | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers |
| ESM 模式 DI | `providers: [IamService, IamRepository]` + 构造器 `@Inject(IamRepository)` 显式注入,避免 `undefined` 运行时错误 |
| Drizzle ORM API | `inArray(col, vals)` 替代不存在的 `.in()`select 返回字段名按 schema 定义(如 `r.iam_roles` 而非 `r.roles` |
| 健康检查依赖 | `readyz` 用 `db.execute(sql\`SELECT 1\`)` 校验连接,不要依赖 typeorm DataSourceIAM 用 Drizzle无 typeorm |
| Gateway 身份传递 | Controller 直接读 `req.headers['x-user-id']` / `x-user-roles`,不要依赖未注册的 AuthMiddleware 的 `AuthenticatedRequest` |
| DEV_MODE 登录 | DEV_MODE=true 时 Gateway 接受 `Bearer dev-token` 注入固定身份IAM 仍支持真实 JWTHS256P2 应改 RS256 |
| P2 公开路径白名单 | Gateway `publicPaths` map 含 `/iam/register`/`/iam/login`/`/iam/refresh`AuthMiddleware 跳过鉴权避免死锁 |
| P2 视口过滤 | `getUserViewports` 按 `requiredPermission` 过滤 + `sortOrder` 字典序排序,无权限要求的视口全员可见 |
| P2 JWT payload | HS256 签名含 `sub/email/roles/dataScope/type`register 自动分配 teacher 角色TEACHER_ROLE_ID 固定 UUID |
### 2.4 core-eduTS/NestJSP3
| 场景 | 技术/规则 |
| -------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka |
| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 |
| Kafka topic | `exam.published` / `homework.graded` / `grade.recorded` |
| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 |
| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费DataAna/Msg |
| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) |
| schema 表 | exams / exam_questions / homework_assignments / homework_submissions / homework_answers / grade_records / outbox_events |
| 场景 | 技术/规则 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka |
| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 |
| Kafka topic | `exam.published` / `homework.graded` / `grade.recorded` |
| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 |
| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费DataAna/Msg |
| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) |
| schema 表 | exams / exam_questions / homework_assignments / homework_submissions / homework_answers / grade_records / outbox_events |
| datetime 列 | Drizzle `datetime` 列需 Date 对象HTTP 请求体里是 ISO 字符串service 层必须 `new Date(input)` 转换否则报 `toISOString is not a function` |
| Kafka 非阻塞 | 开发环境 Kafka 未启动时 `connectKafka()` 必须 try/catch 且 main 中用 `void` 调用,否则阻塞服务启动 |
| 相对 import | `src/<domain>/` 下文件回溯一级用 `../`,不要用 `../../`NestJS ESM 模块) |
| 身份头读取 | Controller 用 `@Req() req` 从 `req.headers['x-user-id']` 读 createdBy/gradedBy不依赖未注册的 AuthMiddleware |
| 健康检查 | health.controller 用 Drizzle `db.execute(sql\`SELECT 1\`)`,不用 typeorm DataSource |
| Outbox 验证 | 业务事务同写 `core_edu_outbox` 表Kafka 未启动时事件 status=failed启动后会重试 |
### 2.5 contentTS/NestJSP4
| 场景 | 技术/规则 |
| ---------- | ------------------------------------------------------------------ |
| 知识图谱 | Neo4j 查询前置依赖图(秒级返回) |
| 题库 CRUD | P4 仅 CRUDP5 引入 ES 实现检索,避免 MySQL FULLTEXT → ES 迁移成本 |
| 双写避免 | Neo4j/ES 不直接双写,由消费 Kafka 事件同步,天然最终一致 |
| Neo4j 写入 | Content 服务写 MySQL 同时发事件,独立 worker 消费事件同步 Neo4j |
| 场景 | 技术/规则 |
| -------------- | ------------------------------------------------------------------------------- |
| 知识图谱 | Neo4j 查询前置依赖图(秒级返回) |
| 题库 CRUD | P4 仅 CRUDP5 引入 ES 实现检索,避免 MySQL FULLTEXT → ES 迁移成本 |
| 双写避免 | Neo4j/ES 不直接双写,由消费 Kafka 事件同步,天然最终一致 |
| Neo4j 写入 | Content 服务写 MySQL 同时发事件,独立 worker 消费事件同步 Neo4j |
| API 字段名 | 请求体用 TS schema 字段名(如 `order`),非 DB 列名(如 `order_num` |
| Neo4j 不可用 | 未设置 NEO4J_URL 时 driver=nullgetNeo4jSession 返回 null业务正常落库 MySQL |
| Neo4j 连接超时 | driver 配置 connectionTimeout:3000避免 Neo4j 不可用时拖慢 HTTP 响应 |
| Drizzle int | drizzle-orm/mysql-core 导出 `int()` 不是 `integer()` |
| db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数,与 core-edu/classes 黄金模板对齐 |
### 2.6 data-anaPython/FastAPIP4
| 场景 | 技术/规则 |
| ------------ | ------------------------------------------------------------------------------ |
| 学情诊断 | ClickHouse 宽表查询5s 内返回 |
| CDC 链路 | Debezium 监听 MySQL binlog → Kafka`mysql.cdc.*`)→ DataAna 消费写 ClickHouse |
| CDC 延迟监控 | Debezium 暴露 lag metrics超阈值告警 |
| 双轨读策略 | 实时查 MySQL 主库(刚提交的成绩),聚合查 CH 宽表(延迟 1-5s 可接受) |
| 幂等消费 | 所有事件消费者必须幂等(基于 event_id 去重) |
| 场景 | 技术/规则 |
| -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 学情诊断 | ClickHouse 宽表查询5s 内返回 |
| CDC 链路 | Debezium 监听 MySQL binlog → Kafka`edu-cdc.next_edu_cloud.<table>`)→ DataAna 消费写 ClickHouse |
| CDC 延迟监控 | Debezium 暴露 lag metrics超阈值告警 |
| 双轨读策略 | 实时查 MySQL 主库(刚提交的成绩),聚合查 CH 宽表(延迟 1-5s 可接受) |
| 幂等消费 | 所有事件消费者必须幂等(基于 event_id 去重) |
| CDC 消费者实现 | `cdc_consumer.py` 用 aiokafka AIOKafkaConsumerlifespan 启动 asyncio.create_task 后台运行 |
| ClickHouse 写入 | `clickhouse_client.upsert_student_dashboard()` 用 client.insert() 写宽表client 为 None 时降级返回 False |
| Debezium 事件解析 | `before/after/source/op/ts_ms` 五字段op=r(快照)/c(新增)/u(更新)/d(删除) |
| 多表关联缓存 | 内存 ExamCache 缓存 exam_id→class_id 映射(来自 core_edu_exams CDC 事件grades 事件触发时查缓存填充宽表 class_id |
| Consumer offset 重置 | `kafka-consumer-groups --reset-offsets --to-earliest --execute` 需先停消费者让 group 处于 Empty 状态 |
| structlog API | 24.x 用 `make_filtering_bound_logger(level)`,旧版 `make_filtering_logger` 已废弃 |
### 2.7 messagingTS/NestJSP5
| 场景 | 技术/规则 |
| -------------- | -------------------------------------------------------------------- |
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
| 通知批量化 | `createNotifications(items)` 单次 INSERT沿用旧项目 dispatcher 模式 |
| 多渠道 | 站内/SMS/邮件/微信in_app 批量 + 其他渠道并行 |
| fan-out 分页 | `getAllUserIds(limit=1000, offset)` 分页遍历 |
| 撤回不乐观更新 | 需服务端返回判断 2 分钟窗口 |
| 场景 | 技术/规则 |
| ------------- | ------------------------------------------------------------------ |
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
| 通知批量化 | `createBatch(items)` 单次 INSERT沿用旧项目 dispatcher 模式 |
| 多渠道 | 站内/SMS/邮件/微信in_app 批量 + 其他渠道并行 |
| fan-out 分页 | `listByUserWithPagination(userId, page, pageSize)` 分页查询 |
| ES 降级 | ES_URL 未设置时 esClient=nullsafeIndex/safeSearch 跳过返回空结果 |
| Push 推送降级 | PUSH_GATEWAY_URL 未设置或连接失败时 try/catch 跳过,不影响 DB 写入 |
| db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数 |
### 2.8 push-gatewayGoP5
| 场景 | 技术/规则 |
| ---------------- | ----------------------------------------------- |
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需发 Kafka 消息 |
| 跨实例同步 | Redis PubSub |
| 离线消息 | 仅推在线用户,离线消息存 MySQL上线时拉取 |
| 场景 | 技术/规则 |
| ---------------- | ---------------------------------------------------------------- |
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需调 /internal/push |
| 跨实例同步 | Redis PubSubRedisURL 配置,预留 P6 实现) |
| 离线消息 | 仅推在线用户,离线消息存 MySQL上线时拉取 |
| 并发写修复 | send chan + 单写协程模式,避免 gorilla/websocket 并发写竞争 |
| DEV_MODE 鉴权 | DEV_MODE=true 时接受 dev-token生产环境必须 JWT 校验 |
| 广播端点 | POST /internal/broadcastbody {event, data},调用 hub.Broadcast |
### 2.9 ai-gatewayPython/FastAPIP5
| 场景 | 技术/规则 |
| ----------------- | ------------------------------------------- |
| LLM Provider 适配 | OpenAI/Anthropiclangchain/litellm 生态 |
| Prompt 模板管理 | 版本管理友好 |
| 流式 SSE | AI 网关 → BFF → 前端三层透传BFF 不缓冲 |
| 用量计费 | 按 token 计费 |
| AI 模块纯服务端 | Zod 验证 + 失败降级返回空(沿用旧项目模式) |
| 场景 | 技术/规则 |
| ----------------- | --------------------------------------------------------------- |
| LLM Provider 适配 | OpenAI 兼容 REST APIhttpx 异步),不引入 openai SDK |
| 降级模式 | API key 为空或调用失败时返回骨架响应,标记 degraded: true |
| 流式 SSE | AI 网关 → BFF → 前端三层透传BFF 不缓冲 |
| 路由前缀 | 业务路由加 /ai 前缀APIRouter prefix="/ai"Gateway 代理 /ai |
| dev_mode tracer | dev_mode=true 时跳过 OTel exporter 初始化 |
### 2.10 shared-proto契约包
@@ -279,12 +330,17 @@
### 2.12 teacher-portal微前端宿主P1 测试页)
| 场景 | 技术/规则 |
| -------------------- | ------------------------------------------------------------------------- |
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
| P1 测试 JWT | 开发工具生成 HS256 tokenP2 起由 IAM 签发 RS256 |
| P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal |
| 场景 | 技术/规则 |
| -------------------- | --------------------------------------------------------------------------------------- |
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
| P1 测试 JWT | 开发工具生成 HS256 tokenP2 起由 IAM 签发 RS256 |
| P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal |
| P2 路由组 + AppShell | `app/(app)/layout.tsx` 用 AppShell 包裹受保护页;`/login` 与 `/` 不套壳 |
| P2 真实 JWT | 登录后 token 存 localStorage`authHeaders()` 读 `Bearer ${getToken()}` |
| P2 视口驱动侧边栏 | AppShell fetch `/teacher/viewports` 渲染左侧导航active 路由高亮 |
| P2 根路径重定向 | `app/page.tsx` 客户端组件 `router.replace(isAuthenticated() ? '/dashboard' : '/login')` |
| fetch headers 类型 | `authHeaders(): Record<string, string>` 显式标注,避免 `{}` 与 `HeadersInit` 不兼容 |
---
@@ -292,17 +348,25 @@
> 按时间倒序50 条上限。AI 发现更好方案时可更新本节。
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-08 | 下午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.ymlci-ts.yml 加 arch-scan + docker-build jobci-go.yml 去掉 golangci-lintlint-staged 预存问题),加 docker-buildci-proto.yml 修复 buf breaking URL从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registrygit.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`workflow_run 触发 + 手动 dispatchRunner 直接执行 docker compose pull && up -d10 次健康检查轮询,失败输出日志。(5) 新增 `infra/docker-compose.deploy.yml`(部署用,镜像来自 Gitea registry连接服务器已有 MySQL/Redis 通过 edu-shared 外部网络)+ `infra/deploy.env.example`(部署环境变量模板)。(6) 编写 `docs/standards/cicd-runbook.md`CI/CD 使用手册,含架构总览/一次性配置/日常使用/镜像管理/部署验证/回滚/常见问题/排查命令/安全注意事项)。**学到**Docker Compose 不支持 `restart_policy`(是 swarm 字段),用 `restart: unless-stopped` 替代Gitea Actions 兼容 GitHub Actions 语法但 `workflow_run` 触发可能不完整,备选手动 dispatch应用容器访问宿主机已有 MySQL/Redis 需通过共享外部网络(`docker network create edu-shared` + `docker network connect`)而非 `host.docker.internal`。 |
| 2026-07-08 | 下午 | api-gateway | **重定向循环修复 + 生产模式部署准备 + 多AI协作文档**(1) 修复 `ERR_TOO_MANY_REDIRECTS`Gin 默认 `RedirectTrailingSlash=true` 导致 `/api/v1/classes` → 301 → `/classes/`Next.js rewrites 代理时形成循环。**修复**`r.RedirectTrailingSlash=false` + 同时注册无尾斜杠路由(`/classes`)与通配符路由(`/classes/*path`)。(2) 新增 DEV_MODE 旁路:`config.go` 加 `DevMode` 字段,`auth.go` 在 `DEV_MODE=true` 时接受 `dev-token` 注入固定身份(生产必须 false。(3) 生产 Docker 化:新建 `apps/teacher-portal/Dockerfile`(多阶段 Next.js build+ `services/api-gateway/Dockerfile`(多阶段 Go 静态编译)+ `infra/docker-compose.prod.yml`(三服务编排,强制 DEV_MODE=false。(4) 编写 `docs/standards/local-dev-runbook.md`(本地启动手册,含端口表/开发模式/生产模式/常见问题)+ `docs/standards/multi-ai-collaboration.md`多AI协作文档含模块分工矩阵/分支命名/PR流程/合并策略/冲突处理/权限矩阵)。**学到**Gin `RedirectTrailingSlash=false` 后需显式注册无尾斜杠路由(`Any("/classes")` + `Any("/classes/*path")`),否则 404Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |
| 2026-07-08 | 全天 | 全局 | **P6 后续工作手册执行**:完整执行 post-p6-followup.md 12 节任务。环境准备pnpm 925 包 + go mod tidy 双服务 + uv sync 双服务 + buf 安装)→ 代码质量校验Go vet/build 0 错误Python ruff 8 错误自动修复)→ arch.db 同步(实现 4 个扫描器骨架,输出 12 模块/233 符号/138 契约)→ project_rules.md P0 修复(迁移到 .trae/rules/17881 字节)→ 004 架构图修复1.1a/1.1b 双图 + 1.2 业务领域列 + 5.4 视口四层)→ P6 集成测试10 Go + 17 bash = 27 用例全通过)→ Helm Chart 演化8 chart lint 通过。**学到**多语言 monorepo 工具链配置需统一镜像源npmmirror/goproxy.cn/tunago.work BOM 字符会导致 `unexpected input character` 错误必须重写文件。 |
| 2026-07-08 | 午 | 全局 | pnpm install 网络失败ECONNRESET→ 配置 `npm config set registry https://registry.npmmirror.com` + `pnpm config set registry https://registry.npmmirror.com` 重试成功。**学到**Windows 下 pnpm 还需配置 `PNPM_HOME` 和 `TMP` 环境变量避免 `_tmp_` 文件 ENOENT 错误。 |
| 2026-07-08 | 上午 | 全局 | project_rules.md 损坏72 字节乱码,从 P1 提交 2ba4250 就损坏git 历史无完整版本)→ 从 CICD 项目完整版迁移到 `e:\Desktop\Edu\.trae\rules\project_rules.md`(按用户要求放 .trae/rules/),按 MIGRATION_GUIDE 4.1 策略矩阵调整为微服务版13 章 17881 字节),删除根目录损坏文件,更新 7 处引用README/MIGRATION_GUIDE/004/known-issues/git-workflow/coding-standards。**学到**:迁移文件后必须 `Get-Item | Select Length` 验证完整性 + 全文搜索引用更新git commit 前运行 cat 检查内容。 |
| 2026-07-08 | 上午 | api-gateway | go.work BOM 字符 + 版本不匹配:`unexpected input character '\ufeff'` 和 `module requires go >= 1.22.0, but go.work lists go 1.22`。**修复**:重写 go.work 去除 BOM版本改为 `go 1.26.0`,移除不存在的 `./packages/shared-go`。**学到**PowerShell `Out-File` 默认加 BOM写 go.work 这类敏感文件应用 `Write` 工具或 `[System.IO.File]::WriteAllText` 指定 UTF8 无 BOM。 |
| 2026-07-08 | 上午 | arch-scan | arch:scan 返回 0 模块 0 符号 → 4 个扫描器ts/go/py/proto都是骨架实现。**修复**:完整实现 4 个扫描器TS 用 regex 提取(避免 ts-morph 对未安装依赖文件解析失败Go/Python 用行首锚定正则Proto 扫描 service/message/rpc。结果12 模块≥10 ✓、233 符号≥100 ✓、138 契约。**学到**ts-morph Project 对未 `pnpm install` 的 workspace 文件会报模块解析失败,改用 regex 更鲁棒scanner.ts main() 开头需 `DELETE FROM` 清空旧数据避免重跑重复。 |
| 2026-07-08 | 下午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案1.1a 技术分层视角(部署/流量/网络边界Users 层标注"场景域用户"BFF 层标注"按场景域分"+ 1.1b 业务领域视角6 DDD 限界上下文 subgraphD1 身份/D2 教学组织/D3 教学核心/D4 内容/D5 沟通/D6 智能洞察。1.2 服务清单新增"业务领域"列。**学到**双图互补1.1a 服务运维/SRE 视角1.1b 服务产品/架构视角同一服务可横跨多领域core-edu 同时承载 D2+D3 |
| 2026-07-08 | 下午 | 004 | 视口四层模型补充5.4 章节L1 导航navigation_config 表)/ L2 路由route_permission + Gateway 校验)/ L3 组件usePermission().hasPermission/ L4 数据DataScope 枚举)。场景域 BFF 复用策略:按使用场景域分 BFF 而非按角色分,教导主任复用 Teacher BFF + 额外管理视口。iam 服务职责:认证 + RBAC + 视口配置 + DataScope + 权限解析 API。**学到**视口既可独立配置RoleViewport 表)也可由权限推导,新角色只需配权限集,视口自动推导。 |
| 2026-07-08 | 下午 | api-gateway | P6 集成测试补充circuit-breaker_test.go5 用例ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted+ ratelimit_test.go5 用例AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets+ test-backup-mysql.sh8 用例 17 断言)。**学到**gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=trueHALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN测试需通过行为503 vs 500而非状态字段验证rateLimiter cleanup 测试需用短周期参数50ms/500ms加速且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
| 2026-07-08 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chartnamespace/configmap/secret/ingress/hpa + 4 环境 values 文件)+ api-gateway 服务级 chart完整迁移自原 deployment.yaml参数化所有字段+ 6 业务服务 chart 桩iam/core-edu/content/msg/data-ana/ai。删除原 api-gateway-deployment.yaml保留 namespace.yaml。**学到**Helm `{{- with ... -}}` 双向修剪会导致标签连在一行(`managed-by: Helmpart-of: edu-platform`),应改为 `{{- with ... }}` 只修剪左侧;`helm lint` 全部通过但 `helm template` 才能发现 YAML 渲染错误,验证时两个都要跑。 |
| 2026-07-07 | 全天 | 全局 | 文档体系初始化从旧项目e:\Desktop\CICDNext.js 单体)迁移 spec + plan + known-issues 模板到新仓库e:\Desktop\Edu微服务架构。known-issues 重组为微服务分区:多语言 monorepo / Docker Compose / protobuf+buf / NestJS / Go Gateway / 可观测性 / 微前端。从旧项目提炼可迁移经验React 19 useOptimistic / Zustand 细粒度选择器 / Tiptap SSR / 请求级去重 / 批量 SQL / 动态导入模式 / arch:scan 串行执行。新增微服务特有经验:契约先行 / Outbox / CDC / 双轨读 / DataScope / 黄金模板复制流程。路线图按 6 阶段组织P1 地基 → P2 身份 → P3 核心教学 → P4 内容分析 → P5 沟通AI → P6 硬化。 |
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-09 | 下午 | 全局 | **P6 硬化:可观测性 + 部署 + CI 硬化**(1) 可观测性栈完善5 个 NestJS 服务 main.ts 添加 `/metrics` Prometheus 端点(用 `app.getHttpAdapter().get('/metrics', ...)` 绕过 DI 容器 get 方法prometheus.yml 从 2 个目标扩展到 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身 + rule_files + alertmanager 关联monitoring compose 用 Loki + Promtail 替换未配置的 blackbox-exporterGrafana datasource 新增 Loki新建 promtail/config.yml 用 docker_sd_configs 仅采集 `edu-*` 容器日志。(2) 部署 compose 扩展:docker-compose.deploy.yml 从 3 服务扩展到 11 服务(+ iam/teacher-bff/core-edu/content/msg/ai/data-ana/push-gateway每个服务带 healthcheck + depends_on 条件 + edu-net/edu-shared 双网络deploy.env.example 补全 Neo4j/ES/ClickHouse/LLM/Kafka 可选依赖配置。(3) teacher-bff 补 health.controller.ts原缺失 /healthz 导致 deploy depends_on service_healthy 失败)。(4) CI 硬化:移除 lint 步骤的 continue-on-errorESLint 9 flat config 已配置完成test 保留 continue-on-error部分服务无 test 脚本)。**学到**NestJS `app.get('/metrics')` 会被解析为 DI 容器 `get(typeOrToken)`,必须用 `app.getHttpAdapter().get()` 才能注册 Express 路由Promtail docker_sd_configs 通过 relabel_configs 的 `regex: '/(edu-.*).*'` 过滤容器名前缀docker-compose.depends_on.condition: service_healthy 要求被依赖服务必须有 healthcheck 配置,否则启动失败。 |
| 2026-07-09 | 下午 | data-ana/infra | **CDC 完整链路实现**MySQL binlog → Debezium Connect → Kafka → data-ana 消费者 → ClickHouse 宽表。(1) MySQL binlog 配置log_bin=ON, binlog_format=ROW, binlog_row_image=FULL, server_id=1用 root 创建 `debezium` 用户授予 REPLICATION SLAVE + REPLICATION CLIENT。(2) Debezium Connect 容器daocloud 禁用 debezium 镜像改用 `quay.io/debezium/connect:2.7`MySQL 容器在 edu-minimal_default 网络,需 `docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达Kafka 必须配置双 listenerINSIDE:kafka:29092 + OUTSIDE:localhost:9092否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connectorPOST :8083/connectors配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial4 张表core_edu_grades/exams/classes/iam_users成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumerlifespan 中 asyncio.create_task 后台运行;按 source.table 路由exams→内存缓存 exam_id→class_id 映射grades→查缓存填 class_id 后 upsert ClickHousereadyz 端点附加 cdc_consumer 状态。(5) ClickHouse 远程访问:默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP。(6) structlog 24.x API`make_filtering_bound_logger(level)` 替代废弃的 `make_filtering_logger`。(7) E2E 验证MySQL INSERT 成绩 → Debezium op=c 事件 → Kafka → 消费者写 ClickHouse 宽表class_id 通过 exam 缓存正确填充)→ /readyz cdc_consumer=running → /analytics/student/student-002/weakness 返回实时 92 分数据。**学到**Debezium 2.x 容器 bootstrap.servers 默认值是 0.0.0.0:9092 必须显式覆盖Kafka 单 listener 配置 localhost 会让容器间通信的客户端拿到 metadata 后切换失败,必须用双 listenerClickHouse users_xml 存储是 readonly 不能用 ALTER USER 修改密码,必须挂载 users.d 配置文件覆盖;消费者 offset 重置必须先停消费者让 group 处于 Empty 状态才能执行 --reset-offsets。 |
| 2026-07-09 | 下午 | 全局 | **P6 硬化ESLint 9 flat config 配置**(1) 根目录创建 `eslint.config.js`ESLint 9 flat config 格式):用 `typescript-eslint` recommended 规则集 + `@eslint/js` recommended + `eslint-config-prettier` 禁用冲突规则;自定义规则:`no-explicit-any` warn + `no-unused-vars` 允许下划线前缀 + 测试文件放宽。(2) 6 个 TS 服务 package.json lint 脚本从 `eslint src --ext .ts` 改为 `eslint src`flat config 不需要 --ext。(3) `lint-staged.config.js` 恢复 `eslint --fix`。(4) 验证classes/content/msg/core-edu 四服务 lint 全部零错误零警告通过。**学到**ESLint 9 flat config 用 `tseslint.config()` 工厂函数组装配置数组;`--ext` 参数在 flat config 模式下被移除ESLint 自动根据 `eslint.config.js` 中的 `files` 匹配;`@typescript-eslint/consistent-type-assertions` 规则选项格式在 v8 中变化(`objectLiteralType` → `objectLiteralTypeAssertions`),配置时需查最新文档。 |
| 2026-07-09 | 午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**(1) msg 服务修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URLelasticsearch.ts ES 降级esClient=null 时 safeIndex/safeSearch 跳过notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点config.go 加 DevMode/RedisURL。(3) ai 服务完善config.py 加 openai_api_key/base_url/dev_mode新建 llm_client.pyhttpx 异步调 OpenAI REST APImain.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg/ai → ai 服务。**学到**gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入FastAPI APIRouter prefix 与 Gateway 代理路径要协调ai 服务加 /ai 前缀Gateway 代理 /ai/*pathLLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 |
| 2026-07-09 | 上午 | content/api-gateway | **P4 内容分析服务端到端打通**(1) content 服务系统性修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 4 种题型校验)。(3) Gateway 路由扩展textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库content-init.sql 4 张表。(5) E2E 验证POST /textbooks 201 → POST /chapters 201字段用 order 非 orderNum→ POST /knowledge-points 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 |
| 2026-07-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**(1) core-edu 服务系统性修复 13 项database.ts 导出 db 常量替代 getDb()env.ts JWT_SECRET 改 optional 加 DEV_MODEkafka.ts connectKafka 加 try/catch 不阻塞启动main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule3 个 controller 路由去前缀去 UseGuards 从 x-user-id 读身份exams/homework service datetime 列 ISO 字符串转 Date 修复 drizzle toISOString 错误;修正 10 处相对 import 路径health/lifecycle 改用 Drizzle 原生查询;新增 core-edu-init.sql 4 张表。(2) Gateway 路由扩展:发现 internal/routing/routing.go 是死代码(未被 main 引用),真正路由在 main.go在 main.go 添加 exams/homework/grades 三组路由(无尾斜杠+通配符);删除 routing.goconfig.go 加 CoreEduServiceURL。(3) DEV_MODE 环境变量问题Go 不自动加载 .env必须在启动前 export DEV_MODE=true 否则 dev-token 被拒 401。(4) E2E 验证POST /exams 201 → GET /exams/:id 200 → GET /exams/class/:id 200 → POST /homework 201 → POST /grades 201 → Outbox 3 条事件正确写入exam.failed 因 Kafka 未启动homework/grade pending。**学到**drizzle datetime 列需 Date 对象不是 ISO 字符串mapToDriverValue 调 toISOStringGo 项目 .env 不会自动加载需显式 export 或 godotenv 库NestJS controller 路由前缀与 Gateway 代理路径要协调Gateway 去掉 /api/v1 后转发controller 用裸路径如 'exams'Outbox 模式业务事务同写验证通过Kafka 未启动时事件 status=failed 但业务数据已落库。 |
| 2026-07-09 | 上午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |
| 2026-07-08 | 晚上 | iam/classes/api-gateway | **P1 端到端链路验证 + IAM 服务修复**:验证 register → JWT → Gateway /iam/me → Gateway /classes CRUD → teacher-portal 前端渲染全链路打通。(1) IAM 服务 14 个 TS 编译错误修复:移除 typeorm/ioredis/kafkajs 依赖IAM 用 Drizzlehealth.controller.ts 改用 `db.execute(sql\`SELECT 1\`)`lifecycle.service.ts 简化为只关闭 Drizzle 连接池Drizzle API 修正(`r.roles`→`r.iam_roles``.in()`→`inArray()`)。(2) NestJS ESM DI 修复iam.module.ts 简化 providers 为 `[IamService, IamRepository]`iam.service.ts 构造器加 `@Inject(IamRepository)`(参考 classes 黄金模板),修复运行时 `Cannot read properties of undefined (reading 'findUserByEmail')`。(3) Gateway /iam/me 404 修复iam.controller.ts 直接读 `req.headers['x-user-id']`替代未注册的`AuthenticatedRequest`。(4) 创建 `scripts/iam-init.sql`建 6 张 IAM 表 + 种子数据。(5) E2E 验证iam:3002 注册/登录 → Gateway /iam/me 200 → Gateway GET /classes 200 → Gateway POST /classes合法 UUID gradeId201 → teacher-portal:3000 首页渲染 200 + 含"班级管理" → Next.js rewrites 透传 dev-token 到 Gateway 全链路通。**学到**NestJS ESM 模式下 DI 无法通过类型推断解析 token必须显式`@Inject(Token)`Drizzle select 返回字段名按 schema 定义而非表名classes.dto.ts 的 gradeId 要求 UUID 格式,测试数据不能用 "grade-12" 这类字符串PowerShell 控制台中文显示为 `?`是编码问题数据库实际存储正确DEV_MODE 下前端用`Bearer dev-token` 即可走通链路,无需真实 JWT。 |
| 2026-07-08 | 下午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.ymlci-ts.yml 加 arch-scan + docker-build jobci-go.yml 去掉 golangci-lintlint-staged 预存问题),加 docker-buildci-proto.yml 修复 buf breaking URL从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registrygit.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`workflow_run 触发 + 手动 dispatchRunner 直接执行 docker compose pull && up -d10 次健康检查轮询,失败输出日志。(5) 新增 `infra/docker-compose.deploy.yml`(部署用,镜像来自 Gitea registry连接服务器已有 MySQL/Redis 通过 edu-shared 外部网络)+ `infra/deploy.env.example`(部署环境变量模板)。(6) 编写 `docs/standards/cicd-runbook.md`CI/CD 使用手册,含架构总览/一次性配置/日常使用/镜像管理/部署验证/回滚/常见问题/排查命令/安全注意事项)。**学到**Docker Compose 不支持 `restart_policy`(是 swarm 字段),用 `restart: unless-stopped` 替代Gitea Actions 兼容 GitHub Actions 语法但 `workflow_run` 触发可能不完整,备选手动 dispatch应用容器访问宿主机已有 MySQL/Redis 需通过共享外部网络(`docker network create edu-shared` + `docker network connect`)而非 `host.docker.internal`。 |
| 2026-07-08 | 下午 | api-gateway | **重定向循环修复 + 生产模式部署准备 + 多AI协作文档**(1) 修复 `ERR_TOO_MANY_REDIRECTS`Gin 默认 `RedirectTrailingSlash=true` 导致 `/api/v1/classes` → 301 → `/classes/`Next.js rewrites 代理时形成循环。**修复**`r.RedirectTrailingSlash=false` + 同时注册无尾斜杠路由(`/classes`)与通配符路由(`/classes/*path`)。(2) 新增 DEV_MODE 旁路:`config.go` 加 `DevMode` 字段,`auth.go` 在 `DEV_MODE=true` 时接受 `dev-token` 注入固定身份(生产必须 false。(3) 生产 Docker 化:新建 `apps/teacher-portal/Dockerfile`(多阶段 Next.js build+ `services/api-gateway/Dockerfile`(多阶段 Go 静态编译)+ `infra/docker-compose.prod.yml`(三服务编排,强制 DEV_MODE=false。(4) 编写 `docs/standards/local-dev-runbook.md`(本地启动手册,含端口表/开发模式/生产模式/常见问题)+ `docs/standards/multi-ai-collaboration.md`多AI协作文档含模块分工矩阵/分支命名/PR流程/合并策略/冲突处理/权限矩阵)。**学到**Gin `RedirectTrailingSlash=false` 后需显式注册无尾斜杠路由(`Any("/classes")` + `Any("/classes/*path")`),否则 404Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |
| 2026-07-08 | 全天 | 全局 | **P6 后续工作手册执行**:完整执行 post-p6-followup.md 12 节任务。环境准备pnpm 925 包 + go mod tidy 双服务 + uv sync 双服务 + buf 安装)→ 代码质量校验Go vet/build 0 错误Python ruff 8 错误自动修复)→ arch.db 同步(实现 4 个扫描器骨架,输出 12 模块/233 符号/138 契约)→ project_rules.md P0 修复(迁移到 .trae/rules/17881 字节)→ 004 架构图修复1.1a/1.1b 双图 + 1.2 业务领域列 + 5.4 视口四层)→ P6 集成测试10 Go + 17 bash = 27 用例全通过)→ Helm Chart 演化8 chart lint 通过)。**学到**:多语言 monorepo 工具链配置需统一镜像源npmmirror/goproxy.cn/tunago.work BOM 字符会导致 `unexpected input character` 错误必须重写文件。 |
| 2026-07-08 | 上午 | 全局 | pnpm install 网络失败ECONNRESET→ 配置 `npm config set registry https://registry.npmmirror.com` + `pnpm config set registry https://registry.npmmirror.com` 重试成功。**学到**Windows 下 pnpm 还需配置 `PNPM_HOME` 和 `TMP` 环境变量避免 `_tmp_` 文件 ENOENT 错误。 |
| 2026-07-08 | 上午 | 全局 | project_rules.md 损坏72 字节乱码,从 P1 提交 2ba4250 就损坏git 历史无完整版本)→ 从 CICD 项目完整版迁移到 `e:\Desktop\Edu\.trae\rules\project_rules.md`(按用户要求放 .trae/rules/),按 MIGRATION_GUIDE 4.1 策略矩阵调整为微服务版13 章 17881 字节),删除根目录损坏文件,更新 7 处引用README/MIGRATION_GUIDE/004/known-issues/git-workflow/coding-standards。**学到**:迁移文件后必须 `Get-Item | Select Length` 验证完整性 + 全文搜索引用更新git commit 前运行 cat 检查内容。 |
| 2026-07-08 | 上午 | api-gateway | go.work BOM 字符 + 版本不匹配:`unexpected input character '\ufeff'` 和 `module requires go >= 1.22.0, but go.work lists go 1.22`。**修复**:重写 go.work 去除 BOM版本改为 `go 1.26.0`,移除不存在的 `./packages/shared-go`。**学到**PowerShell `Out-File` 默认加 BOM写 go.work 这类敏感文件应用 `Write` 工具或 `[System.IO.File]::WriteAllText` 指定 UTF8 无 BOM。 |
| 2026-07-08 | 上午 | arch-scan | arch:scan 返回 0 模块 0 符号 → 4 个扫描器ts/go/py/proto都是骨架实现。**修复**:完整实现 4 个扫描器TS 用 regex 提取(避免 ts-morph 对未安装依赖文件解析失败Go/Python 用行首锚定正则Proto 扫描 service/message/rpc。结果12 模块≥10 ✓、233 符号≥100 ✓、138 契约。**学到**ts-morph Project 对未 `pnpm install` 的 workspace 文件会报模块解析失败,改用 regex 更鲁棒scanner.ts main() 开头需 `DELETE FROM` 清空旧数据避免重跑重复。 |
| 2026-07-08 | 下午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案1.1a 技术分层视角(部署/流量/网络边界Users 层标注"场景域用户"BFF 层标注"按场景域分"+ 1.1b 业务领域视角6 DDD 限界上下文 subgraphD1 身份/D2 教学组织/D3 教学核心/D4 内容/D5 沟通/D6 智能洞察。1.2 服务清单新增"业务领域"列。**学到**双图互补1.1a 服务运维/SRE 视角1.1b 服务产品/架构视角同一服务可横跨多领域core-edu 同时承载 D2+D3。 |
| 2026-07-08 | 下午 | 004 | 视口四层模型补充5.4 章节L1 导航navigation_config 表)/ L2 路由route_permission + Gateway 校验)/ L3 组件usePermission().hasPermission/ L4 数据DataScope 枚举)。场景域 BFF 复用策略:按使用场景域分 BFF 而非按角色分,教导主任复用 Teacher BFF + 额外管理视口。iam 服务职责:认证 + RBAC + 视口配置 + DataScope + 权限解析 API。**学到**视口既可独立配置RoleViewport 表)也可由权限推导,新角色只需配权限集,视口自动推导。 |
| 2026-07-08 | 下午 | api-gateway | P6 集成测试补充circuit-breaker_test.go5 用例ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted+ ratelimit_test.go5 用例AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets+ test-backup-mysql.sh8 用例 17 断言)。**学到**gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=trueHALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN测试需通过行为503 vs 500而非状态字段验证rateLimiter cleanup 测试需用短周期参数50ms/500ms加速且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
| 2026-07-08 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chartnamespace/configmap/secret/ingress/hpa + 4 环境 values 文件)+ api-gateway 服务级 chart完整迁移自原 deployment.yaml参数化所有字段+ 6 业务服务 chart 桩iam/core-edu/content/msg/data-ana/ai。删除原 api-gateway-deployment.yaml保留 namespace.yaml。**学到**Helm `{{- with ... -}}` 双向修剪会导致标签连在一行(`managed-by: Helmpart-of: edu-platform`),应改为 `{{- with ... }}` 只修剪左侧;`helm lint` 全部通过但 `helm template` 才能发现 YAML 渲染错误,验证时两个都要跑。 |
| 2026-07-07 | 全天 | 全局 | 文档体系初始化从旧项目e:\Desktop\CICDNext.js 单体)迁移 spec + plan + known-issues 模板到新仓库e:\Desktop\Edu微服务架构。known-issues 重组为微服务分区:多语言 monorepo / Docker Compose / protobuf+buf / NestJS / Go Gateway / 可观测性 / 微前端。从旧项目提炼可迁移经验React 19 useOptimistic / Zustand 细粒度选择器 / Tiptap SSR / 请求级去重 / 批量 SQL / 动态导入模式 / arch:scan 串行执行。新增微服务特有经验:契约先行 / Outbox / CDC / 双轨读 / DataScope / 黄金模板复制流程。路线图按 6 阶段组织P1 地基 → P2 身份 → P3 核心教学 → P4 内容分析 → P5 沟通AI → P6 硬化。 |

60
eslint.config.js Normal file
View File

@@ -0,0 +1,60 @@
// ESLint 9 flat config
// 项目级配置TypeScript + NestJS + Next.js + 设计令牌规则
const js = require('@eslint/js');
const tseslint = require('typescript-eslint');
const prettierConfig = require('eslint-config-prettier');
module.exports = tseslint.config(
// 全局忽略
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/.next/**',
'**/coverage/**',
'**/*.config.js',
'**/*.config.mjs',
'scripts/arch-scan/**',
],
},
// 基础 JS 规则
js.configs.recommended,
// TypeScript 规则
...tseslint.configs.recommended,
// 项目级自定义规则
{
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
},
rules: {
// 禁止 any未知类型用 unknown
'@typescript-eslint/no-explicit-any': 'warn',
// 未使用变量允许下划线前缀
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
// 允许 console开发环境
'no-console': 'off',
},
},
// 测试文件放宽规则
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/test/**'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// 禁用与 Prettier 冲突的规则
prettierConfig,
);

View File

@@ -0,0 +1,14 @@
<clickhouse>
<!-- 覆盖 default-user.xml 的本地限制,允许 default 用户从任意 IP 用密码访问 -->
<users>
<default>
<password>clickhouse</password>
<networks>
<ip>::/0</ip>
</networks>
<profile>default</profile>
<quota>default</quota>
<access_management>1</access_management>
</default>
</users>
</clickhouse>

View File

@@ -27,12 +27,29 @@ JWT_AUDIENCE=next-edu-cloud
API_GATEWAY_PORT=8080
TEACHER_PORTAL_PORT=3000
# ============ KafkaP3 阶段启用P1 留空============
# ============ KafkaP3+ 启用,留空则 Outbox publisher 持续重试============
KAFKA_BROKERS=
# ============ 可观测性P6 阶段启用P1 留空============
OTEL_EXPORTER_OTLP_ENDPOINT=
# ============ 可观测性P6 启用============
# OTLP collector 端点,留空则服务跳过 trace 上报
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
LOG_LEVEL=info
# ============ 镜像 tag由 CI 注入,手动部署时可改============
# IMAGE_TAG=latest ← 默认 latestCI 通过 export IMAGE_TAG 覆盖
# ============ Neo4jcontent 服务,留空则降级模式============
NEO4J_URL=
NEO4J_PASSWORD=
# ============ Elasticsearchmsg 服务,留空则降级模式)============
ES_URL=
# ============ ClickHousedata-ana 服务,留空则降级模式)============
CLICKHOUSE_HOST=
CLICKHOUSE_PORT=8123
CLICKHOUSE_DATABASE=edu_analytics
CLICKHOUSE_USER=
CLICKHOUSE_PASSWORD=
# ============ LLM 配置ai 服务,留空则降级模式)============
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
ANTHROPIC_API_KEY=

View File

@@ -18,6 +18,9 @@
name: edu
services:
# ============================================================
# 应用服务10 个api-gateway + 3 Go/Python + 6 NestJS
# ============================================================
api-gateway:
build:
context: ./repo/services/api-gateway
@@ -34,6 +37,11 @@ services:
CLASSES_SERVICE_URL: http://classes:3001
IAM_SERVICE_URL: http://iam:3002
TEACHER_BFF_URL: http://teacher-bff:3003
CORE_EDU_SERVICE_URL: http://core-edu:3004
CONTENT_SERVICE_URL: http://content:3005
DATA_ANA_SERVICE_URL: http://data-ana:3006
MSG_SERVICE_URL: http://msg:3007
AI_SERVICE_URL: http://ai:3008
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
ports:
@@ -59,7 +67,6 @@ services:
restart: unless-stopped
environment:
PORT: 3001
# 连接服务器已有的 MySQL容器名 edu-mysql需在 edu-shared 网络)
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
@@ -75,6 +82,214 @@ services:
- edu-net
- edu-shared
iam:
build:
context: ./repo
dockerfile: services/iam/Dockerfile
container_name: edu-iam
restart: unless-stopped
environment:
PORT: 3002
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
JWT_SECRET: ${JWT_SECRET}
JWT_ISSUER: ${JWT_ISSUER:-next-edu-cloud}
JWT_AUDIENCE: ${JWT_AUDIENCE:-next-edu-cloud}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
NODE_ENV: production
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3002/healthz"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 5
networks:
- edu-net
- edu-shared
teacher-bff:
build:
context: ./repo
dockerfile: services/teacher-bff/Dockerfile
container_name: edu-teacher-bff
restart: unless-stopped
environment:
PORT: 3003
IAM_SERVICE_URL: http://iam:3002
CLASSES_SERVICE_URL: http://classes:3001
CORE_EDU_SERVICE_URL: http://core-edu:3004
LOG_LEVEL: ${LOG_LEVEL:-info}
NODE_ENV: production
depends_on:
iam:
condition: service_healthy
classes:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3003/healthz"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
networks:
- edu-net
- edu-shared
core-edu:
build:
context: ./repo
dockerfile: services/core-edu/Dockerfile
container_name: edu-core-edu
restart: unless-stopped
environment:
PORT: 3004
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
NODE_ENV: production
DEV_MODE: "false"
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3004/healthz"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 5
networks:
- edu-net
- edu-shared
content:
build:
context: ./repo
dockerfile: services/content/Dockerfile
container_name: edu-content
restart: unless-stopped
environment:
PORT: 3005
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
NEO4J_URL: ${NEO4J_URL:-}
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-}
ES_URL: ${ES_URL:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
NODE_ENV: production
DEV_MODE: "false"
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3005/healthz"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 5
networks:
- edu-net
- edu-shared
msg:
build:
context: ./repo
dockerfile: services/msg/Dockerfile
container_name: edu-msg
restart: unless-stopped
environment:
PORT: 3007
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
ES_URL: ${ES_URL:-}
PUSH_GATEWAY_URL: http://push-gateway:8081
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
NODE_ENV: production
DEV_MODE: "false"
depends_on:
push-gateway:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3007/healthz"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 5
networks:
- edu-net
- edu-shared
ai:
build:
context: ./repo/services/ai
dockerfile: Dockerfile
container_name: edu-ai
restart: unless-stopped
environment:
PORT: 3008
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
DEV_MODE: "false"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3008/healthz')"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
networks:
- edu-net
- edu-shared
data-ana:
build:
context: ./repo/services/data-ana
dockerfile: Dockerfile
container_name: edu-data-ana
restart: unless-stopped
environment:
PORT: 3006
CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-}
CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123}
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-edu_analytics}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-}
OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
DEV_MODE: "false"
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3006/healthz')"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
networks:
- edu-net
- edu-shared
push-gateway:
build:
context: ./repo/services/push-gateway
dockerfile: Dockerfile
container_name: edu-push-gateway
restart: unless-stopped
environment:
PUSH_GATEWAY_PORT: 8081
JWT_SECRET: ${JWT_SECRET}
REDIS_URL: ${REDIS_URL}
DEV_MODE: "false"
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8081/healthz"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
networks:
- edu-net
- edu-shared
teacher-portal:
build:
context: ./repo

View File

@@ -14,6 +14,8 @@ volumes:
name: edu-alertmanager-data
grafana-data:
name: edu-grafana-data
loki-data:
name: edu-loki-data
services:
# ============================================================
@@ -104,16 +106,35 @@ services:
- edu-network
# ============================================================
# blackbox-exporter - 黑盒探测HTTP / TCP / ICMP
# Loki - 日志聚合P6 硬化新增
# ============================================================
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
container_name: edu-blackbox-exporter
loki:
image: grafana/loki:3.2.1
container_name: edu-loki
profiles: ["monitoring"]
restart: unless-stopped
command: -config.file=/etc/loki/local-config.yaml
ports:
- "9115:9115"
- "3100:3100"
volumes:
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
- loki-data:/loki
networks:
- edu-network
# ============================================================
# Promtail - 日志采集(收集 Docker 容器日志发送到 Loki
# ============================================================
promtail:
image: grafana/promtail:3.2.1
container_name: edu-promtail
profiles: ["monitoring"]
restart: unless-stopped
command: -config.file=/etc/promtail/config.yml
volumes:
- ./promtail/config.yml:/etc/promtail/config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- edu-network
depends_on:
- loki

View File

@@ -1,7 +1,8 @@
name: edu-full
services:
mysql:
image: mysql:8.0
# 镜像加速:通过 daocloud 镜像源绕过 docker.io 被墙问题
image: docker.m.daocloud.io/library/mysql:8.0
container_name: edu-mysql
restart: unless-stopped
environment:
@@ -20,7 +21,7 @@ services:
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
image: docker.m.daocloud.io/library/redis:7-alpine
container_name: edu-redis
restart: unless-stopped
ports:
@@ -33,7 +34,7 @@ services:
timeout: 3s
retries: 5
kafka:
image: confluentinc/cp-kafka:7.6.0
image: docker.m.daocloud.io/confluentinc/cp-kafka:7.6.0
container_name: edu-kafka
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
@@ -43,7 +44,13 @@ services:
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
# 双 listenerINSIDE 容器间互访kafka:29092OUTSIDE 主机访问(localhost:9092
# 必须用 INSIDE 作为 inter.broker.listener.name否则 Debezium Connect 拿到 metadata
# 后会切回 advertised.listeners 中的 localhost导致连接失败
KAFKA_LISTENERS: INSIDE://:29092,OUTSIDE://:9092
KAFKA_ADVERTISED_LISTENERS: INSIDE://kafka:29092,OUTSIDE://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
ports:
@@ -54,7 +61,7 @@ services:
timeout: 10s
retries: 5
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
image: docker.m.daocloud.io/confluentinc/cp-zookeeper:7.6.0
container_name: edu-zookeeper
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
@@ -66,7 +73,7 @@ services:
timeout: 5s
retries: 5
clickhouse:
image: clickhouse/clickhouse-server:24.3
image: docker.m.daocloud.io/clickhouse/clickhouse-server:24.3
container_name: edu-clickhouse
profiles: ["p4", "p5", "p6"]
restart: unless-stopped
@@ -75,13 +82,15 @@ services:
- "9000:9000"
volumes:
- clickhouse_data:/var/lib/clickhouse
# 覆盖默认 default-user.xml 限制(默认仅允许 127.0.0.1/::1 无密码访问)
- ./clickhouse/users.d/custom-users.xml:/etc/clickhouse-server/users.d/custom-users.xml:ro
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 10s
timeout: 5s
retries: 5
neo4j:
image: neo4j:5.20
image: docker.m.daocloud.io/library/neo4j:5.20
container_name: edu-neo4j
profiles: ["p4", "p5", "p6"]
restart: unless-stopped
@@ -98,6 +107,7 @@ services:
timeout: 10s
retries: 5
elasticsearch:
# Elastic 官方镜像在 docker.elastic.co非 Docker Hub通常不被墙
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
container_name: edu-es
profiles: ["p5", "p6"]
@@ -116,7 +126,7 @@ services:
timeout: 10s
retries: 5
jaeger:
image: jaegertracing/all-in-one:1.57
image: docker.m.daocloud.io/jaegertracing/all-in-one:1.57
container_name: edu-jaeger
profiles: ["observability"]
restart: unless-stopped
@@ -125,8 +135,52 @@ services:
ports:
- "16686:16686"
- "4318:4318"
# ============================================================
# Debezium Connect - CDC 链路核心
# 监听 MySQL binlog → 写入 Kafka topic
# topic 命名约定:<prefix>.<database>.<table>(如 edu-cdc.next_edu_cloud.grades
# ============================================================
debezium-connect:
image: quay.io/debezium/connect:2.7
container_name: edu-debezium
profiles: ["p4", "p5", "p6"]
restart: unless-stopped
depends_on:
kafka:
condition: service_healthy
environment:
# Kafka Connect 基础配置Debezium 2.x 容器映射规则:环境变量名大写 → connect 配置项)
# 必须用 INSIDE listener (kafka:29092),否则会拿到 OUTSIDE 的 localhost metadata 导致连不上
BOOTSTRAP_SERVERS: kafka:29092
GROUP_ID: edu-debezium
CONFIG_STORAGE_TOPIC: edu-connect-configs
OFFSET_STORAGE_TOPIC: edu-connect-offsets
STATUS_STORAGE_TOPIC: edu-connect-status
# 内部 converter 配置(必须与 Debezium 事件格式一致)
CONFIG_STORAGE_REPLICATION_FACTOR: "1"
OFFSET_STORAGE_REPLICATION_FACTOR: "1"
STATUS_STORAGE_REPLICATION_FACTOR: "1"
KEY_CONVERTER: org.apache.kafka.connect.json.JsonConverter
VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter
KEY_CONVERTER_SCHEMAS_ENABLE: "false"
VALUE_CONVERTER_SCHEMAS_ENABLE: "false"
# 监听端口
REST_PORT: 8083
REST_ADVERTISED_HOST_NAME: debezium-connect
# 日志级别
LOG_LEVEL: INFO
ports:
- "8083:8083"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8083/connectors"]
interval: 15s
timeout: 5s
start_period: 30s
retries: 10
networks:
- default
prometheus:
image: prom/prometheus:v0.51.0
image: docker.m.daocloud.io/prom/prometheus:v2.51.0
container_name: edu-prometheus
profiles: ["observability"]
restart: unless-stopped
@@ -135,14 +189,14 @@ services:
ports:
- "9090:9090"
grafana:
image: grafana/grafana:10.4.0
image: docker.m.daocloud.io/grafana/grafana:10.4.0
container_name: edu-grafana
profiles: ["observability"]
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
ports:
- "3001:3001"
- "3030:3000"
volumes:
- grafana_data:/var/lib/grafana
volumes:
@@ -151,4 +205,4 @@ volumes:
clickhouse_data:
neo4j_data:
es_data:
grafana_data:
grafana_data:

View File

@@ -13,3 +13,12 @@ datasources:
timeInterval: "15s"
httpMethod: POST
manageAlerts: false
- name: Loki
type: loki
uid: loki
access: proxy
url: http://loki:3100
editable: false
jsonData:
maxLines: 1000

View File

@@ -2,11 +2,93 @@ global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'api-gateway'
static_configs:
- targets: ['host.docker.internal:8080']
# 告警规则文件
rule_files:
- /etc/prometheus/rules.yml
# 告警管理器
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
scrape_configs:
# ============================================================
# 应用服务NestJS / FastAPI 暴露 /metrics
# ============================================================
- job_name: 'classes-service'
static_configs:
- targets: ['host.docker.internal:3001']
- targets: ['host.docker.internal:3001']
labels:
service: classes
- job_name: 'iam-service'
static_configs:
- targets: ['host.docker.internal:3002']
labels:
service: iam
- job_name: 'teacher-bff'
static_configs:
- targets: ['host.docker.internal:3003']
labels:
service: teacher-bff
- job_name: 'core-edu-service'
static_configs:
- targets: ['host.docker.internal:3004']
labels:
service: core-edu
- job_name: 'content-service'
static_configs:
- targets: ['host.docker.internal:3005']
labels:
service: content
- job_name: 'data-ana-service'
static_configs:
- targets: ['host.docker.internal:3006']
labels:
service: data-ana
- job_name: 'msg-service'
static_configs:
- targets: ['host.docker.internal:3007']
labels:
service: msg
- job_name: 'ai-service'
static_configs:
- targets: ['host.docker.internal:3008']
labels:
service: ai
# ============================================================
# 基础设施docker-compose.minimal/minimal.override
# ============================================================
- job_name: 'mysql'
static_configs:
- targets: ['host.docker.internal:9104']
labels:
service: mysql
- job_name: 'redis'
static_configs:
- targets: ['host.docker.internal:9121']
labels:
service: redis
# ============================================================
# 监控栈自身
# ============================================================
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'node-exporter'
static_configs:
- targets: ['node-exporter:9100']
labels:
service: node-exporter

28
infra/promtail/config.yml Normal file
View File

@@ -0,0 +1,28 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
# 采集 Docker 容器日志
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
# 只采集带 edu- 前缀的容器日志
- source_labels: ['__meta_docker_container_name']
regex: '/(edu-.*).*'
action: keep
# 提取容器名作为 label
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: container_name
# 提取镜像名
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream

View File

@@ -1,6 +1,5 @@
module.exports = {
// ESLint 9 flat config 迁移完成后恢复:['eslint --fix', 'prettier --write']
'*.{ts,tsx}': ['prettier --write'],
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
// Go 工具链不在 git hook PATH 中Go 文件格式化由 go fmt 手动执行
// golangci-lint 安装后恢复:['gofmt -w', 'golangci-lint run --fix']
'*.py': ['ruff check --fix', 'ruff format'],

View File

@@ -18,13 +18,17 @@
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.1.0",
"lint-staged": "^15.0.0",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"@eslint/js": "^9.0.0",
"@types/node": "^22.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.0.0",
"husky": "^9.1.0",
"lint-staged": "^15.0.0",
"prettier": "^3.3.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/node": "^22.0.0",
"prettier": "^3.3.0"
"typescript-eslint": "^8.0.0"
}
}

273
pnpm-lock.yaml generated
View File

@@ -14,9 +14,18 @@ importers:
'@commitlint/config-conventional':
specifier: ^19.0.0
version: 19.8.1
'@eslint/js':
specifier: ^9.0.0
version: 9.39.4
'@types/node':
specifier: ^22.0.0
version: 22.20.0
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1)
eslint-config-prettier:
specifier: ^9.0.0
version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
husky:
specifier: ^9.1.0
version: 9.1.7
@@ -32,6 +41,9 @@ importers:
typescript:
specifier: ^5.6.0
version: 5.9.3
typescript-eslint:
specifier: ^8.0.0
version: 8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
apps/teacher-portal:
dependencies:
@@ -57,6 +69,9 @@ importers:
autoprefixer:
specifier: ^10.4.0
version: 10.5.2(postcss@8.5.16)
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7)
postcss:
specifier: ^8.4.0
version: 8.5.16
@@ -376,6 +391,9 @@ importers:
'@types/bcrypt':
specifier: ^5.0.0
version: 5.0.2
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/jsonwebtoken':
specifier: ^9.0.0
version: 9.0.10
@@ -452,6 +470,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.20.0
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
typescript:
specifier: ^5.6.0
version: 5.9.3
@@ -485,10 +506,16 @@ importers:
rxjs:
specifier: ^7.8.0
version: 7.8.2
zod:
specifier: ^3.23.0
version: 3.25.76
devDependencies:
'@nestjs/cli':
specifier: ^10.4.0
version: 10.4.9
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/node':
specifier: ^22.0.0
version: 22.20.0
@@ -2353,6 +2380,65 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@typescript-eslint/eslint-plugin@8.63.0':
resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.63.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.63.0':
resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.63.0':
resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.63.0':
resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.63.0':
resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.63.0':
resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.63.0':
resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.63.0':
resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.63.0':
resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.63.0':
resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/coverage-v8@2.1.9':
resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==}
peerDependencies:
@@ -3214,6 +3300,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-prettier@9.1.2:
resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
@@ -3230,6 +3322,10 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@9.39.4:
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3597,6 +3693,10 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -4964,6 +5064,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -5056,6 +5162,13 @@ packages:
typeorm-aurora-data-api-driver:
optional: true
typescript-eslint@8.63.0:
resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript@5.7.2:
resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==}
engines: {node: '>=14.17'}
@@ -5796,6 +5909,11 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@@ -7214,6 +7332,97 @@ snapshots:
'@types/uuid@10.0.0': {}
'@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/type-utils': 8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.63.0
eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.63.0
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.63.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3)
'@typescript-eslint/types': 8.63.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.63.0':
dependencies:
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/visitor-keys': 8.63.0
'@typescript-eslint/tsconfig-utils@8.63.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.63.0': {}
'@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.63.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3)
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/visitor-keys': 8.63.0
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.63.0':
dependencies:
'@typescript-eslint/types': 8.63.0
eslint-visitor-keys: 5.0.1
'@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.20.0)(terser@5.48.0))':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -8085,6 +8294,10 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-scope@5.1.1:
dependencies:
esrecurse: 4.3.0
@@ -8099,6 +8312,49 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
eslint-visitor-keys@5.0.1: {}
eslint@9.39.4(jiti@1.21.7):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.9
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 1.21.7
transitivePeerDependencies:
- supports-color
eslint@9.39.4(jiti@2.6.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@@ -8559,6 +8815,8 @@ snapshots:
ignore@5.3.2: {}
ignore@7.0.5: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -9940,6 +10198,10 @@ snapshots:
tree-kill@1.2.2: {}
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-interface-checker@0.1.13: {}
ts-morph@24.0.0:
@@ -10004,6 +10266,17 @@ snapshots:
- babel-plugin-macros
- supports-color
typescript-eslint@8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.63.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.7.2: {}
typescript@5.9.3: {}

View File

@@ -0,0 +1,37 @@
-- ClickHouse 数据库初始化脚本
-- 适用服务data-ana数据分析
-- 表结构student_dashboard_view学生学情宽表/ student_errors错题本
-- 与 services/data-ana/src/data_ana/clickhouse_client.py 中的查询字段对齐
--
-- 使用方式(启用 ClickHouse 时执行一次):
-- clickhouse-client --multiquery < scripts/clickhouse-init.sql
-- 注意ClickHouse 为可选依赖,未配置时 data-ana 服务进入降级模式。
-- 数据库
CREATE DATABASE IF NOT EXISTS edu_analytics;
-- 学生学情宽表(考试/班级/知识点维度)
CREATE TABLE IF NOT EXISTS edu_analytics.student_dashboard_view (
student_id String,
class_id String,
exam_id String,
subject_id String,
score Float64,
rank_in_class UInt32,
knowledge_point_id String,
mastery_level Float32,
error_count UInt32,
last_updated DateTime
) ENGINE = MergeTree()
ORDER BY (student_id, class_id, exam_id);
-- 学生错题表(错题本)
CREATE TABLE IF NOT EXISTS edu_analytics.student_errors (
student_id String,
question_id String,
knowledge_point_id String,
error_count UInt32,
last_error_time DateTime,
content String
) ENGINE = MergeTree()
ORDER BY (student_id, knowledge_point_id);

47
scripts/content-init.sql Normal file
View File

@@ -0,0 +1,47 @@
-- Content 服务数据库初始化脚本
-- 表content_textbooks / content_chapters / content_knowledge_points / content_questions
-- 表结构与 services/content/src 下的 Drizzle schema 对齐
CREATE TABLE IF NOT EXISTS content_textbooks (
id CHAR(36) NOT NULL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
subject_id CHAR(36) NOT NULL,
grade_id CHAR(36) NOT NULL,
version VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_textbooks_subject_id (subject_id),
INDEX idx_textbooks_grade_id (grade_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS content_chapters (
id CHAR(36) NOT NULL PRIMARY KEY,
textbook_id CHAR(36) NOT NULL,
title VARCHAR(200) NOT NULL,
order_num INT NOT NULL,
parent_id CHAR(36),
INDEX idx_chapters_textbook_id (textbook_id),
INDEX idx_chapters_parent_id (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS content_knowledge_points (
id CHAR(36) NOT NULL PRIMARY KEY,
chapter_id CHAR(36) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
INDEX idx_knowledge_points_chapter_id (chapter_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS content_questions (
id CHAR(36) NOT NULL PRIMARY KEY,
knowledge_point_id CHAR(36) NOT NULL,
type VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
answer TEXT,
explanation TEXT,
difficulty INT DEFAULT 3,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_questions_knowledge_point_id (knowledge_point_id),
INDEX idx_questions_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

65
scripts/core-edu-init.sql Normal file
View File

@@ -0,0 +1,65 @@
-- CoreEdu 服务数据库初始化脚本
-- 表core_edu_exams / core_edu_homework / core_edu_grades / core_edu_outbox
CREATE TABLE IF NOT EXISTS core_edu_exams (
id CHAR(36) NOT NULL PRIMARY KEY,
class_id CHAR(36) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
exam_date DATETIME NOT NULL,
duration VARCHAR(50) NOT NULL,
total_score VARCHAR(10) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
created_by CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_exams_class_id (class_id),
INDEX idx_exams_status (status),
INDEX idx_exams_created_by (created_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS core_edu_homework (
id CHAR(36) NOT NULL PRIMARY KEY,
class_id CHAR(36) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
due_date DATETIME NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'assigned',
created_by CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_homework_class_id (class_id),
INDEX idx_homework_status (status),
INDEX idx_homework_created_by (created_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS core_edu_grades (
id CHAR(36) NOT NULL PRIMARY KEY,
student_id CHAR(36) NOT NULL,
exam_id CHAR(36),
homework_id CHAR(36),
score VARCHAR(10) NOT NULL,
feedback TEXT,
graded_by CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_grades_student_id (student_id),
INDEX idx_grades_exam_id (exam_id),
INDEX idx_grades_homework_id (homework_id),
INDEX idx_grades_graded_by (graded_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS core_edu_outbox (
id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id CHAR(36) NOT NULL,
aggregate_type VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
INDEX idx_outbox_status (status),
INDEX idx_outbox_aggregate (aggregate_type, aggregate_id),
INDEX idx_outbox_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,22 @@
{
"name": "edu-mysql-source",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "edu-mysql",
"database.port": "3306",
"database.user": "debezium",
"database.password": "debezium-pwd",
"database.allowPublicKeyRetrieval": "true",
"database.server.id": "184054",
"topic.prefix": "edu-cdc",
"database.include.list": "next_edu_cloud",
"table.include.list": "next_edu_cloud.core_edu_grades,next_edu_cloud.core_edu_exams,next_edu_cloud.classes,next_edu_cloud.iam_users",
"schema.history.internal.kafka.bootstrap.servers": "kafka:29092",
"schema.history.internal.kafka.topic": "edu-connect-schema-history",
"snapshot.mode": "initial",
"key.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"key.converter.schemas.enable": "false",
"value.converter.schemas.enable": "false"
}
}

115
scripts/iam-init.sql Normal file
View File

@@ -0,0 +1,115 @@
-- IAM 服务表结构P2 身份阶段)
CREATE TABLE IF NOT EXISTS `iam_users` (
`id` CHAR(36) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`data_scope` ENUM('self','class','grade','school','district','all') NOT NULL DEFAULT 'self',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `iam_users_email_unique` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `iam_roles` (
`id` CHAR(36) NOT NULL,
`name` VARCHAR(50) NOT NULL,
`description` VARCHAR(255) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `iam_roles_name_unique` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `iam_user_roles` (
`user_id` CHAR(36) NOT NULL,
`role_id` CHAR(36) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `iam_permissions` (
`id` CHAR(36) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`resource` VARCHAR(50) NOT NULL,
`action` VARCHAR(50) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `iam_permissions_name_unique` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `iam_role_permissions` (
`role_id` CHAR(36) NOT NULL,
`permission_id` CHAR(36) NOT NULL,
PRIMARY KEY (`role_id`, `permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `iam_refresh_tokens` (
`id` CHAR(36) NOT NULL,
`user_id` CHAR(36) NOT NULL,
`token_hash` VARCHAR(255) NOT NULL,
`expires_at` TIMESTAMP NOT NULL,
`revoked_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_iam_refresh_tokens_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 视口配置表4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据)
CREATE TABLE IF NOT EXISTS `iam_role_viewports` (
`id` CHAR(36) NOT NULL,
`role_id` CHAR(36) NOT NULL,
`viewport_key` VARCHAR(50) NOT NULL,
`label` VARCHAR(100) NOT NULL,
`route` VARCHAR(200) NOT NULL,
`icon` VARCHAR(50) NULL,
`sort_order` VARCHAR(10) NOT NULL DEFAULT '0',
`required_permission` VARCHAR(100) NULL,
`component_config` TEXT NULL,
PRIMARY KEY (`id`),
INDEX `idx_iam_role_viewports_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 种子数据:默认角色
INSERT IGNORE INTO `iam_roles` (`id`, `name`, `description`) VALUES
('00000000-0000-0000-0000-000000000001', 'teacher', '教师角色'),
('00000000-0000-0000-0000-000000000002', 'admin', '管理员角色');
-- 种子数据:权限点(固定 UUID 便于 role_permissions 引用)
INSERT IGNORE INTO `iam_permissions` (`id`, `name`, `resource`, `action`) VALUES
('00000000-0000-0000-0000-000000000101', 'classes:read', 'classes', 'read'),
('00000000-0000-0000-0000-000000000102', 'classes:create', 'classes', 'create'),
('00000000-0000-0000-0000-000000000103', 'classes:update', 'classes', 'update'),
('00000000-0000-0000-0000-000000000104', 'classes:delete', 'classes', 'delete'),
('00000000-0000-0000-0000-000000000201', 'iam:user:read', 'iam', 'user:read'),
('00000000-0000-0000-0000-000000000202', 'iam:user:manage', 'iam', 'user:manage'),
('00000000-0000-0000-0000-000000000203', 'iam:role:manage', 'iam', 'role:manage');
-- 种子数据teacher 角色权限classes CRUD + user:read
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000102'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000103'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000104'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000201');
-- 种子数据admin 角色权限(全部权限)
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000102'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000103'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000104'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000201'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000202'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000203');
-- 种子数据teacher 角色视口L1 导航)
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`) VALUES
('00000000-0000-0000-0000-000000000a01', '00000000-0000-0000-0000-000000000001', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL),
('00000000-0000-0000-0000-000000000a02', '00000000-0000-0000-0000-000000000001', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read'),
('00000000-0000-0000-0000-000000000a03', '00000000-0000-0000-0000-000000000001', 'profile', '个人中心', '/profile', 'user', '9', NULL);
-- 种子数据admin 角色视口L1 导航)
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`) VALUES
('00000000-0000-0000-0000-000000000b01', '00000000-0000-0000-0000-000000000002', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL),
('00000000-0000-0000-0000-000000000b02', '00000000-0000-0000-0000-000000000002', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read'),
('00000000-0000-0000-0000-000000000b03', '00000000-0000-0000-0000-000000000002', 'iam', '用户管理', '/iam/users', 'shield', '2', 'iam:user:read'),
('00000000-0000-0000-0000-000000000b04', '00000000-0000-0000-0000-000000000002', 'profile', '个人中心', '/profile', 'user', '9', NULL);

26
scripts/msg-init.sql Normal file
View File

@@ -0,0 +1,26 @@
-- Msg 服务数据库初始化脚本
-- 表msg_notifications / msg_notification_preferences
CREATE TABLE IF NOT EXISTS msg_notifications (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
channel VARCHAR(20) NOT NULL DEFAULT 'in_app',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_notifications_user_id (user_id),
INDEX idx_notifications_is_read (is_read),
INDEX idx_notifications_created_at (created_at),
INDEX idx_notifications_user_read (user_id, is_read)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS msg_notification_preferences (
user_id CHAR(36) NOT NULL PRIMARY KEY,
email_enabled BOOLEAN NOT NULL DEFAULT TRUE,
sms_enabled BOOLEAN NOT NULL DEFAULT FALSE,
push_enabled BOOLEAN NOT NULL DEFAULT TRUE,
in_app_enabled BOOLEAN NOT NULL DEFAULT TRUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -7,12 +7,27 @@ class Settings(BaseSettings):
"""应用配置."""
port: int = 3008
# LLM 配置(可选,为空时降级返回骨架响应)
openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
anthropic_api_key: str = ""
# 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错
dev_mode: str = "false"
# 可观测性
otel_endpoint: str = "http://localhost:4318"
log_level: str = "info"
model_config = {"env_file": ".env", "env_prefix": ""}
@property
def is_dev(self) -> bool:
"""是否处于开发模式."""
return self.dev_mode.lower() == "true"
@property
def llm_available(self) -> bool:
"""LLM 是否可用(至少一个 provider 配置了 API key."""
return bool(self.openai_api_key or self.anthropic_api_key)
settings = Settings()

View File

@@ -0,0 +1,137 @@
"""LLM 客户端 - 使用 httpx 直接调用 OpenAI 兼容 REST API。
设计要点:
- 不依赖 openai SDK纯 httpx 异步调用
- api_key 为空或调用失败时返回 None / yield 降级骨架数据
- 调用方据此决定是否进入降级路径
"""
from collections.abc import AsyncGenerator
from typing import Any
import httpx
import structlog
logger = structlog.get_logger()
# 非流式请求默认超时(秒)
DEFAULT_TIMEOUT: float = 30.0
# 流式请求建立连接超时(秒);读取通过迭代器控制
STREAM_CONNECT_TIMEOUT: float = 30.0
# 流式读取单次 chunk 超时(秒)
STREAM_READ_TIMEOUT: float = 60.0
def _build_url(base_url: str) -> str:
"""拼接 chat completions 端点 URL."""
return f"{base_url.rstrip('/')}/chat/completions"
def _build_headers(api_key: str) -> dict[str, str]:
"""构建请求头."""
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async def chat_completion(
messages: list[dict[str, Any]],
model: str,
temperature: float,
api_key: str,
base_url: str,
) -> dict[str, Any] | None:
"""非流式调用 LLM。
Returns:
OpenAI 兼容的响应 dictapi_key 为空或调用失败时返回 None由调用方降级
"""
if not api_key:
logger.warning("llm_chat_completion_no_api_key_degraded")
return None
url = _build_url(base_url)
headers = _build_headers(api_key)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": False,
}
try:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as exc:
logger.error(
"llm_chat_completion_http_error",
status_code=exc.response.status_code,
body=exc.response.text[:500],
)
return None
except Exception as exc: # noqa: BLE001 - 顶层兜底,所有异常均降级
logger.error("llm_chat_completion_failed", error=str(exc))
return None
async def chat_completion_stream(
messages: list[dict[str, Any]],
model: str,
temperature: float,
api_key: str,
base_url: str,
) -> AsyncGenerator[str, None]:
"""流式调用 LLM以 SSE 格式(``data: <chunk>\\n\\n``yield。
api_key 为空或调用失败时 yield 降级骨架数据,保证下游始终能消费。
"""
if not api_key:
logger.warning("llm_stream_no_api_key_degraded")
yield (
'data: {"choices":[{"delta":{"content":"[degraded] LLM API key not configured"}}]}\n\n'
)
yield "data: [DONE]\n\n"
return
url = _build_url(base_url)
headers = _build_headers(api_key)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
timeout = httpx.Timeout(
connect=STREAM_CONNECT_TIMEOUT,
read=STREAM_READ_TIMEOUT,
write=STREAM_CONNECT_TIMEOUT,
pool=STREAM_CONNECT_TIMEOUT,
)
try:
async with (
httpx.AsyncClient(timeout=timeout) as client,
client.stream("POST", url, json=payload, headers=headers) as resp,
):
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line or not line.startswith("data: "):
continue
yield f"{line}\n\n"
if line.strip() == "data: [DONE]":
return
except httpx.HTTPStatusError as exc:
logger.error(
"llm_stream_http_error_degraded",
status_code=exc.response.status_code,
)
yield 'data: {"choices":[{"delta":{"content":"[degraded] LLM stream HTTP error"}}]}\n\n'
yield "data: [DONE]\n\n"
except Exception as exc: # noqa: BLE001 - 顶层兜底,所有异常均降级
logger.error("llm_stream_failed_degraded", error=str(exc))
yield 'data: {"choices":[{"delta":{"content":"[degraded] LLM stream error"}}]}\n\n'
yield "data: [DONE]\n\n"

View File

@@ -1,9 +1,11 @@
"""AI 网关服务入口."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
import structlog
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from fastapi.responses import StreamingResponse
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
@@ -12,25 +14,45 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app
from pydantic import BaseModel
from .config import settings
from .llm_client import chat_completion, chat_completion_stream
logger = structlog.get_logger()
tracer = trace.get_tracer(__name__)
def init_tracer() -> None:
"""初始化 OpenTelemetry."""
"""初始化 OpenTelemetry.
endpoint 从 settings.otel_endpoint 读取dev_mode=true 时跳过 exporter
初始化,避免本地无 collector 时报错。
"""
if settings.is_dev:
logger.info("dev_mode_tracer_skipped", dev_mode=settings.dev_mode)
return
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
endpoint = f"{settings.otel_endpoint.rstrip('/')}/v1/traces"
exporter = OTLPSpanExporter(endpoint=endpoint)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
logger.info("tracer_initialized", otel_endpoint=endpoint)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期."""
init_tracer()
logger.info("ai service starting")
logger.info(
"ai_service_starting",
llm_available=settings.llm_available,
dev_mode=settings.is_dev,
openai_base_url=settings.openai_base_url,
)
if not settings.llm_available:
logger.warning("ai_service_llm_degraded_no_api_key")
yield
logger.info("ai service stopping")
logger.info("ai_service_stopping")
app = FastAPI(
@@ -41,11 +63,14 @@ app = FastAPI(
app.mount("/metrics", make_asgi_app())
# 业务路由加 /ai 前缀Gateway 代理 /api/v1/ai/* → /ai/*
router = APIRouter(prefix="/ai")
class ChatRequest(BaseModel):
"""聊天请求."""
messages: list[dict]
messages: list[dict[str, Any]]
model: str = "gpt-4o-mini"
temperature: float = 0.7
stream: bool = False
@@ -56,56 +81,157 @@ class ChatResponse(BaseModel):
content: str
model: str
usage: dict
usage: dict[str, Any]
degraded: bool = False
def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]:
"""从 OpenAI 响应中抽取 (content, model, usage)。"""
if result is None:
return "", "", {}
choices = result.get("choices", [])
content = ""
if choices:
content = choices[0].get("message", {}).get("content", "") or ""
model = result.get("model", "") or ""
usage = result.get("usage", {}) or {}
return content, model, usage
@app.get("/healthz")
async def healthz():
"""健康检查."""
async def healthz() -> dict[str, Any]:
"""健康检查liveness."""
return {"status": "ok", "service": "ai"}
@app.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest):
"""LLM 聊天接口."""
@app.get("/readyz")
async def readyz() -> dict[str, Any]:
"""就绪检查readiness.
LLM 未配置时仍返回 200但标记 degraded=true调用方可据此判断是否路由流量。
"""
llm_configured = settings.llm_available
return {
"status": "ok",
"service": "ai",
"llm_configured": llm_configured,
"degraded": not llm_configured,
"openai_base_url": settings.openai_base_url,
}
@router.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest) -> ChatResponse:
"""LLM 聊天接口(无 API key 时降级返回骨架响应)."""
with tracer.start_as_current_span("ai_chat"):
# P5 骨架:实际调用 OpenAI/Anthropic API
# 需要从环境变量获取 API key
return {
"content": "P5 skeleton - LLM integration pending",
"model": req.model,
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
result = await chat_completion(
messages=req.messages,
model=req.model,
temperature=req.temperature,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
)
if result is None:
logger.warning("chat_degraded", model=req.model)
return ChatResponse(
content="[degraded] LLM unavailable - returning skeleton response",
model=req.model,
usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
degraded=True,
)
content, model, usage = _extract_content(result)
return ChatResponse(
content=content,
model=model or req.model,
usage=usage,
degraded=False,
)
@app.post("/chat/stream")
async def chat_stream(req: ChatRequest):
"""流式聊天SSE."""
@router.post("/chat/stream")
async def chat_stream(req: ChatRequest) -> StreamingResponse:
"""流式聊天(SSE无 API key 时降级返回骨架 SSE."""
async def generate():
async def generate() -> AsyncGenerator[str, None]:
with tracer.start_as_current_span("ai_chat_stream"):
# P5 骨架:流式调用 LLM
yield "data: P5 skeleton\n\n"
yield "data: [DONE]\n\n"
async for chunk in chat_completion_stream(
messages=req.messages,
model=req.model,
temperature=req.temperature,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
):
yield chunk
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/generate/question")
async def generate_question(prompt: str):
"""生成题目."""
@router.post("/generate/question")
async def generate_question(prompt: str) -> dict[str, Any]:
"""生成题目(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("generate_question"):
messages = [
{
"role": "system",
"content": "You are an educational question generator. "
"Generate a clear, concise question based on the user's prompt.",
},
{"role": "user", "content": prompt},
]
result = await chat_completion(
messages=messages,
model="gpt-4o-mini",
temperature=0.7,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
)
if result is None:
logger.warning("generate_question_degraded", prompt=prompt[:100])
return {
"success": True,
"data": {"question": "[degraded] question generation skeleton"},
"degraded": True,
}
content, _, _ = _extract_content(result)
return {
"success": True,
"data": {"question": "P5 skeleton - question generation pending"},
"data": {"question": content},
"degraded": False,
}
@app.post("/optimize/expression")
async def optimize_expression(text: str):
"""优化表达."""
@router.post("/optimize/expression")
async def optimize_expression(text: str) -> dict[str, Any]:
"""优化表达(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("optimize_expression"):
messages = [
{
"role": "system",
"content": "You are a writing assistant. "
"Optimize the user's text for clarity, conciseness, and tone.",
},
{"role": "user", "content": text},
]
result = await chat_completion(
messages=messages,
model="gpt-4o-mini",
temperature=0.5,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
)
if result is None:
logger.warning("optimize_expression_degraded", text=text[:100])
return {
"success": True,
"data": {"optimized": "[degraded] expression optimization skeleton"},
"degraded": True,
}
content, _, _ = _extract_content(result)
return {
"success": True,
"data": {"optimized": "P5 skeleton - expression optimization pending"},
"data": {"optimized": content},
"degraded": False,
}
app.include_router(router)

View File

@@ -13,6 +13,11 @@ type Config struct {
ClassesServiceURL string
IamServiceURL string
TeacherBffURL string
CoreEduServiceURL string
ContentServiceURL string
DataAnaServiceURL string
MsgServiceURL string
AiServiceURL string
OTLPEndpoint string
LogLevel string
DevMode bool
@@ -27,6 +32,11 @@ func Load() *Config {
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"),
TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"),
CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"),
ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"),
DataAnaServiceURL: getEnv("DATA_ANA_SERVICE_URL", "http://localhost:3006"),
MsgServiceURL: getEnv("MSG_SERVICE_URL", "http://localhost:3007"),
AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false),

View File

@@ -10,6 +10,20 @@ import (
"github.com/google/uuid"
)
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
var publicPaths = map[string]bool{
"/iam/register": true,
"/iam/login": true,
"/iam/refresh": true,
}
// isPublicPath 判断请求路径是否属于公开路径(无需鉴权)
// 匹配规则:去掉 /api/v1 前缀后,与 publicPaths 精确匹配
func isPublicPath(path string) bool {
stripped := strings.TrimPrefix(path, "/api/v1")
return publicPaths[stripped]
}
// AuthMiddleware 验证 JWT 并注入用户信息到请求头
// P1 用 HS256P2 改 RS256IAM 签发)
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
@@ -20,6 +34,12 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return
}
// 公开路径白名单register/login/refresh 无需鉴权)
if isPublicPath(c.Request.URL.Path) {
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{

View File

@@ -1,56 +0,0 @@
package routing
import (
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/middleware"
"github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/gin-gonic/gin"
)
// Setup 配置路由
func Setup(cfg *config.Config) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// 中间件
r.Use(middleware.RequestIDMiddleware())
r.Use(gin.Recovery())
// 健康检查(无需鉴权)
r.GET("/healthz", healthz)
// API v1 组(需要鉴权)
api := r.Group("/api/v1")
api.Use(middleware.AuthMiddleware(cfg))
{
// classes 服务路由
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL)
if err != nil {
panic("failed to create classes proxy: " + err.Error())
}
api.Any("/classes/*path", proxy.ProxyHandler(classesProxy))
// IAM 服务路由(身份与访问管理)
iamProxy, err := proxy.NewProxy(cfg.IamServiceURL)
if err != nil {
panic("failed to create iam proxy: " + err.Error())
}
api.Any("/iam/*path", proxy.ProxyHandler(iamProxy))
// Teacher BFF 路由(教师聚合层)
bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL)
if err != nil {
panic("failed to create teacher-bff proxy: " + err.Error())
}
api.Any("/teacher/*path", proxy.ProxyHandler(bffProxy))
}
return r
}
func healthz(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "api-gateway",
})
}

View File

@@ -79,6 +79,62 @@ func main() {
bffHandler := proxy.ProxyHandler(bffProxy)
api.Any("/teacher", bffHandler)
api.Any("/teacher/*path", bffHandler)
// core-edu 服务路由(考试/作业/成绩)
// 注:同时注册无尾斜杠与通配符两条路由,与 classes/iam/teacher 一致。
coreEduProxy, err := proxy.NewProxy(cfg.CoreEduServiceURL)
if err != nil {
log.Fatalf("failed to create core-edu proxy: %v", err)
}
coreEduHandler := proxy.ProxyHandler(coreEduProxy)
api.Any("/exams", coreEduHandler)
api.Any("/exams/*path", coreEduHandler)
api.Any("/homework", coreEduHandler)
api.Any("/homework/*path", coreEduHandler)
api.Any("/grades", coreEduHandler)
api.Any("/grades/*path", coreEduHandler)
// content 服务路由(教材/章节/知识点/题库)
contentProxy, err := proxy.NewProxy(cfg.ContentServiceURL)
if err != nil {
log.Fatalf("failed to create content proxy: %v", err)
}
contentHandler := proxy.ProxyHandler(contentProxy)
api.Any("/textbooks", contentHandler)
api.Any("/textbooks/*path", contentHandler)
api.Any("/chapters", contentHandler)
api.Any("/chapters/*path", contentHandler)
api.Any("/knowledge-points", contentHandler)
api.Any("/knowledge-points/*path", contentHandler)
api.Any("/questions", contentHandler)
api.Any("/questions/*path", contentHandler)
// msg 服务路由(通知/消息)
msgProxy, err := proxy.NewProxy(cfg.MsgServiceURL)
if err != nil {
log.Fatalf("failed to create msg proxy: %v", err)
}
msgHandler := proxy.ProxyHandler(msgProxy)
api.Any("/notifications", msgHandler)
api.Any("/notifications/*path", msgHandler)
// ai 服务路由AI 聊天/生成/优化)
aiProxy, err := proxy.NewProxy(cfg.AiServiceURL)
if err != nil {
log.Fatalf("failed to create ai proxy: %v", err)
}
aiHandler := proxy.ProxyHandler(aiProxy)
api.Any("/ai", aiHandler)
api.Any("/ai/*path", aiHandler)
// data-ana 服务路由(学情诊断/错题本)
dataAnaProxy, err := proxy.NewProxy(cfg.DataAnaServiceURL)
if err != nil {
log.Fatalf("failed to create data-ana proxy: %v", err)
}
dataAnaHandler := proxy.ProxyHandler(dataAnaProxy)
api.Any("/analytics", dataAnaHandler)
api.Any("/analytics/*path", dataAnaHandler)
}
srv := &http.Server{

View File

@@ -10,7 +10,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src --ext .ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -1,31 +1,39 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
import { env } from './config/env.js';
import { logger } from './shared/observability/logger.js';
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create(AppModule, {
logger: ['log', 'error', 'warn'],
logger: ["log", "error", "warn"],
});
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
await app.listen(env.PORT);
logger.info({ port: env.PORT }, 'Classes service started');
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
process.on('SIGTERM', async () => {
await app.listen(env.PORT);
logger.info({ port: env.PORT }, "Classes service started");
process.on("SIGTERM", async () => {
await app.close();
await shutdownTracer();
});
}
bootstrap().catch((err: unknown) => {
logger.error({ err }, 'Failed to start classes service');
logger.error({ err }, "Failed to start classes service");
process.exit(1);
});

View File

@@ -1,24 +1,28 @@
import promClient from 'prom-client';
import promClient from "prom-client";
const registry = new promClient.Registry();
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register
registry.setDefaultLabels({ service: 'classes' });
registry.setDefaultLabels({ service: "classes" });
registry.registerMetric(
new promClient.Counter({
name: 'classes_requests_total',
help: 'Total number of class requests',
labelNames: ['method', 'endpoint', 'status'],
name: "classes_requests_total",
help: "Total number of class requests",
labelNames: ["method", "endpoint", "status"],
}),
);
registry.registerMetric(
new promClient.Histogram({
name: 'classes_request_duration_seconds',
help: 'Class request duration in seconds',
labelNames: ['method', 'endpoint'],
name: "classes_request_duration_seconds",
help: "Class request duration in seconds",
labelNames: ["method", "endpoint"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// 自动收集 Node.js 进程级指标CPU/内存/事件循环/GC等
// 这些指标无需业务代码埋点prom-client 自动采集
promClient.collectDefaultMetrics({ register: registry });
export { registry as metricsRegistry };

View File

@@ -8,7 +8,7 @@
"build": "nest build",
"start": "node dist/main.js",
"test": "vitest run",
"lint": "eslint src --ext .ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -1,7 +1,17 @@
import { Module } from '@nestjs/common';
import { TextbooksModule } from './textbooks/textbooks.module.js';
import { Module } from "@nestjs/common";
import { TextbooksModule } from "./textbooks/textbooks.module.js";
import { ChaptersModule } from "./chapters/chapters.module.js";
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
import { QuestionsModule } from "./questions/questions.module.js";
import { HealthModule } from "./shared/health/health.module.js";
@Module({
imports: [TextbooksModule],
imports: [
TextbooksModule,
ChaptersModule,
KnowledgePointsModule,
QuestionsModule,
HealthModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,61 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from "@nestjs/common";
import {
ChaptersService,
type CreateChapterInput,
type UpdateChapterInput,
} from "./chapters.service.js";
import type { Chapter } from "./chapters.schema.js";
@Controller("chapters")
export class ChaptersController {
constructor(private readonly service: ChaptersService) {}
@Post()
async create(
@Body() body: CreateChapterInput,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.createChapter(body);
return { success: true, data: result };
}
@Get("textbook/:textbookId")
async listByTextbook(
@Param("textbookId") textbookId: string,
): Promise<{ success: true; data: Chapter[] }> {
const data = await this.service.listChaptersByTextbook(textbookId);
return { success: true, data };
}
@Get(":id")
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: Chapter }> {
const data = await this.service.getChapter(id);
return { success: true, data };
}
@Put(":id")
async update(
@Param("id") id: string,
@Body() body: UpdateChapterInput,
): Promise<{ success: true; data: { success: true } }> {
await this.service.updateChapter(id, body);
return { success: true, data: { success: true } };
}
@Delete(":id")
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.deleteChapter(id);
return { success: true, data: { success: true } };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ChaptersController } from "./chapters.controller.js";
import { ChaptersService } from "./chapters.service.js";
@Module({
controllers: [ChaptersController],
providers: [ChaptersService],
exports: [ChaptersService],
})
export class ChaptersModule {}

View File

@@ -0,0 +1,35 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import { chapters, type Chapter, type NewChapter } from "./chapters.schema.js";
export class ChaptersRepository {
async findById(id: string): Promise<Chapter | undefined> {
const [result] = await db
.select()
.from(chapters)
.where(eq(chapters.id, id))
.limit(1);
return result;
}
async findByTextbookId(textbookId: string): Promise<Chapter[]> {
return db
.select()
.from(chapters)
.where(eq(chapters.textbookId, textbookId));
}
async create(data: NewChapter): Promise<void> {
await db.insert(chapters).values(data);
}
async update(id: string, data: Partial<NewChapter>): Promise<void> {
await db.update(chapters).set(data).where(eq(chapters.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(chapters).where(eq(chapters.id, id));
}
}
export const chaptersRepository = new ChaptersRepository();

View File

@@ -0,0 +1,5 @@
export {
chapters,
type Chapter,
type NewChapter,
} from "../textbooks/textbooks.schema.js";

View File

@@ -0,0 +1,62 @@
import { randomUUID } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { chaptersRepository } from "./chapters.repository.js";
import type { Chapter } from "./chapters.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateChapterInput {
textbookId: string;
title: string;
order: number;
parentId?: string;
}
export interface UpdateChapterInput {
title?: string;
order?: number;
parentId?: string;
}
@Injectable()
export class ChaptersService {
async createChapter(input: CreateChapterInput): Promise<{ id: string }> {
if (!input.textbookId || !input.title || input.order === undefined) {
throw new ValidationError("textbookId, title, order are required");
}
const id = randomUUID();
await chaptersRepository.create({
id,
textbookId: input.textbookId,
title: input.title,
order: input.order,
parentId: input.parentId,
});
return { id };
}
async getChapter(id: string): Promise<Chapter> {
const chapter = await chaptersRepository.findById(id);
if (!chapter) {
throw new NotFoundError("Chapter", id);
}
return chapter;
}
async listChaptersByTextbook(textbookId: string): Promise<Chapter[]> {
return chaptersRepository.findByTextbookId(textbookId);
}
async updateChapter(id: string, data: UpdateChapterInput): Promise<void> {
await this.getChapter(id);
await chaptersRepository.update(id, data);
}
async deleteChapter(id: string): Promise<void> {
await this.getChapter(id);
await chaptersRepository.delete(id);
}
}

View File

@@ -1,24 +1,16 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from './env.js';
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
const pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
export function getDb() {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return drizzle(pool);
}
export const db = drizzle(pool);
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
await pool.end();
}

View File

@@ -1,17 +1,22 @@
import { z } from 'zod';
import { z } from "zod";
const envSchema = z.object({
PORT: z.string().default('3005'),
PORT: z.string().default("3005"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
NEO4J_URL: z.string().url(),
NEO4J_PASSWORD: z.string(),
ES_URL: z.string().url(),
JWT_SECRET: z.string(),
JWT_ISSUER: z.string().default('next-edu-cloud'),
NEO4J_URL: z.string().url().optional(),
NEO4J_PASSWORD: z.string().optional(),
ES_URL: z.string().url().optional(),
JWT_SECRET: z.string().optional(),
JWT_ISSUER: z.string().default("next-edu-cloud"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
.default("info"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
DEV_MODE: z.string().optional().default("false"),
});
export type Env = z.infer<typeof envSchema>;
@@ -19,8 +24,11 @@ export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment configuration');
console.error(
"❌ Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment configuration");
}
return result.data;
}

View File

@@ -1,11 +1,38 @@
import neo4j from 'neo4j-driver';
import { env } from './env.js';
import neo4j from "neo4j-driver";
import type { Driver, Session } from "neo4j-driver";
import { env } from "./env.js";
export const neo4jDriver = neo4j.driver(
env.NEO4J_URL,
neo4j.auth.basic('neo4j', env.NEO4J_PASSWORD)
);
// Neo4j driver 创建为惰性初始化:未配置 NEO4J_URL / NEO4J_PASSWORD 时
// driver 保持 null服务仍可正常启动。所有依赖 Neo4j 的查询在
// driver 为 null 时返回空结果或抛出可控错误,不会阻塞主流程。
let driver: Driver | null = null;
try {
if (env.NEO4J_URL && env.NEO4J_PASSWORD) {
driver = neo4j.driver(
env.NEO4J_URL,
neo4j.auth.basic("neo4j", env.NEO4J_PASSWORD),
// 连接超时 3sNeo4j 不可用时快速失败,避免拖慢 HTTP 响应
{ connectionTimeout: 3000, maxConnectionLifetime: 60_000 },
);
}
} catch (err) {
console.warn(
"Neo4j driver init failed, running without Neo4j:",
err instanceof Error ? err.message : String(err),
);
driver = null;
}
export function getNeo4jSession(): Session | null {
if (!driver) {
return null;
}
return driver.session();
}
export async function closeNeo4j(): Promise<void> {
await neo4jDriver.close();
if (driver) {
await driver.close();
}
}

View File

@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from "@nestjs/common";
import {
KnowledgePointsService,
type CreateKnowledgePointInput,
type UpdateKnowledgePointInput,
type PrerequisiteNode,
} from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
@Controller("knowledge-points")
export class KnowledgePointsController {
constructor(private readonly service: KnowledgePointsService) {}
@Post()
async create(
@Body() body: CreateKnowledgePointInput,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.createKnowledgePoint(body);
return { success: true, data: result };
}
@Get("chapter/:chapterId")
async listByChapter(
@Param("chapterId") chapterId: string,
): Promise<{ success: true; data: KnowledgePoint[] }> {
const data = await this.service.listByChapter(chapterId);
return { success: true, data };
}
@Get(":id/prerequisites")
async getPrerequisites(
@Param("id") id: string,
): Promise<{ success: true; data: PrerequisiteNode[] }> {
const data = await this.service.getPrerequisites(id);
return { success: true, data };
}
@Get(":id")
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: KnowledgePoint }> {
const data = await this.service.getKnowledgePoint(id);
return { success: true, data };
}
@Post(":id/prerequisites/:prerequisiteId")
async addPrerequisite(
@Param("id") id: string,
@Param("prerequisiteId") prerequisiteId: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.addPrerequisite(id, prerequisiteId);
return { success: true, data: { success: true } };
}
@Put(":id")
async update(
@Param("id") id: string,
@Body() body: UpdateKnowledgePointInput,
): Promise<{ success: true; data: { success: true } }> {
await this.service.updateKnowledgePoint(id, body);
return { success: true, data: { success: true } };
}
@Delete(":id")
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.deleteKnowledgePoint(id);
return { success: true, data: { success: true } };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { KnowledgePointsController } from "./knowledge-points.controller.js";
import { KnowledgePointsService } from "./knowledge-points.service.js";
@Module({
controllers: [KnowledgePointsController],
providers: [KnowledgePointsService],
exports: [KnowledgePointsService],
})
export class KnowledgePointsModule {}

View File

@@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import {
knowledgePoints,
type KnowledgePoint,
type NewKnowledgePoint,
} from "./knowledge-points.schema.js";
export class KnowledgePointsRepository {
async findById(id: string): Promise<KnowledgePoint | undefined> {
const [result] = await db
.select()
.from(knowledgePoints)
.where(eq(knowledgePoints.id, id))
.limit(1);
return result;
}
async findByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
return db
.select()
.from(knowledgePoints)
.where(eq(knowledgePoints.chapterId, chapterId));
}
async create(data: NewKnowledgePoint): Promise<void> {
await db.insert(knowledgePoints).values(data);
}
async update(id: string, data: Partial<NewKnowledgePoint>): Promise<void> {
await db
.update(knowledgePoints)
.set(data)
.where(eq(knowledgePoints.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id));
}
}
export const knowledgePointsRepository = new KnowledgePointsRepository();

View File

@@ -0,0 +1,5 @@
export {
knowledgePoints,
type KnowledgePoint,
type NewKnowledgePoint,
} from "../textbooks/textbooks.schema.js";

View File

@@ -0,0 +1,164 @@
import { randomUUID } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common";
import { knowledgePointsRepository } from "./knowledge-points.repository.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
import { getNeo4jSession } from "../config/neo4j.js";
import {
InternalError,
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateKnowledgePointInput {
chapterId: string;
title: string;
description?: string;
}
export interface UpdateKnowledgePointInput {
title?: string;
description?: string;
}
export interface PrerequisiteNode {
id: string;
title: string;
}
@Injectable()
export class KnowledgePointsService {
private readonly logger = new Logger(KnowledgePointsService.name);
async createKnowledgePoint(
input: CreateKnowledgePointInput,
): Promise<{ id: string }> {
if (!input.chapterId || !input.title) {
throw new ValidationError("chapterId, title are required");
}
const id = randomUUID();
await knowledgePointsRepository.create({
id,
chapterId: input.chapterId,
title: input.title,
description: input.description,
});
// Neo4j创建知识点节点。非阻塞——失败仅记录日志不影响 MySQL 写入。
await this.safeCreateNode(id, input.title);
return { id };
}
async getKnowledgePoint(id: string): Promise<KnowledgePoint> {
const kp = await knowledgePointsRepository.findById(id);
if (!kp) {
throw new NotFoundError("KnowledgePoint", id);
}
return kp;
}
async listByChapter(chapterId: string): Promise<KnowledgePoint[]> {
return knowledgePointsRepository.findByChapterId(chapterId);
}
async updateKnowledgePoint(
id: string,
data: UpdateKnowledgePointInput,
): Promise<void> {
await this.getKnowledgePoint(id);
await knowledgePointsRepository.update(id, data);
}
async deleteKnowledgePoint(id: string): Promise<void> {
await this.getKnowledgePoint(id);
await knowledgePointsRepository.delete(id);
}
async getPrerequisites(
knowledgePointId: string,
): Promise<PrerequisiteNode[]> {
const session = getNeo4jSession();
if (!session) {
return [];
}
try {
const result = await session.executeRead((tx) =>
tx.run(
`MATCH (kp:KnowledgePoint {id: $id})<-[:PREREQUISITE_OF*1..5]-(prereq)
RETURN prereq.id as id, prereq.title as title`,
{ id: knowledgePointId },
),
);
return result.records.map((r): PrerequisiteNode => {
const rawId: unknown = r.get("id");
const rawTitle: unknown = r.get("title");
return {
id: typeof rawId === "string" ? rawId : String(rawId),
title: typeof rawTitle === "string" ? rawTitle : String(rawTitle),
};
});
} catch (err) {
this.logger.warn(
`Neo4j getPrerequisites failed: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
} finally {
await session.close();
}
}
async addPrerequisite(id: string, prerequisiteId: string): Promise<void> {
if (id === prerequisiteId) {
throw new ValidationError(
"A knowledge point cannot be a prerequisite of itself",
);
}
// 先校验两个知识点在 MySQL 中都存在
await this.getKnowledgePoint(id);
await this.getKnowledgePoint(prerequisiteId);
const session = getNeo4jSession();
if (!session) {
throw new InternalError(
"Neo4j is not available, cannot add prerequisite",
);
}
try {
await session.executeWrite((tx) =>
tx.run(
`MATCH (kp:KnowledgePoint {id: $kpId}), (prereq:KnowledgePoint {id: $prereqId})
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
{ kpId: id, prereqId: prerequisiteId },
),
);
} finally {
await session.close();
}
}
private async safeCreateNode(id: string, title: string): Promise<void> {
const session = getNeo4jSession();
if (!session) {
return;
}
try {
await session.executeWrite((tx) =>
tx.run("MERGE (kp:KnowledgePoint {id: $id, title: $title})", {
id,
title,
}),
);
} catch (err) {
this.logger.warn(
`Neo4j createNode failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
await session.close();
}
}
}

View File

@@ -1,31 +1,54 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
import { env } from './config/env.js';
import { logger } from './shared/observability/logger.js';
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js";
import { closeDb } from "./config/database.js";
import { closeNeo4j } from "./config/neo4j.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create(AppModule, {
logger: ['log', 'error', 'warn'],
logger: ["log", "error", "warn"],
});
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
await app.listen(env.PORT);
logger.info({ port: env.PORT }, 'Content service started');
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
process.on('SIGTERM', async () => {
await app.close();
await app.listen(env.PORT);
logger.info({ port: env.PORT }, "Content service started");
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully...");
await closeNeo4j();
await closeDb();
await shutdownTracer();
await app.close();
process.exit(0);
});
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully...");
await closeNeo4j();
await closeDb();
await shutdownTracer();
await app.close();
process.exit(0);
});
}
bootstrap().catch((err: unknown) => {
logger.error({ err }, 'Failed to start content service');
logger.error({ err }, "Failed to start content service");
process.exit(1);
});

View File

@@ -0,0 +1,61 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from "@nestjs/common";
import {
QuestionsService,
type CreateQuestionInput,
type UpdateQuestionInput,
} from "./questions.service.js";
import type { Question } from "./questions.schema.js";
@Controller("questions")
export class QuestionsController {
constructor(private readonly service: QuestionsService) {}
@Post()
async create(
@Body() body: CreateQuestionInput,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.createQuestion(body);
return { success: true, data: result };
}
@Get("knowledge-point/:knowledgePointId")
async listByKnowledgePoint(
@Param("knowledgePointId") knowledgePointId: string,
): Promise<{ success: true; data: Question[] }> {
const data = await this.service.listByKnowledgePoint(knowledgePointId);
return { success: true, data };
}
@Get(":id")
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: Question }> {
const data = await this.service.getQuestion(id);
return { success: true, data };
}
@Put(":id")
async update(
@Param("id") id: string,
@Body() body: UpdateQuestionInput,
): Promise<{ success: true; data: { success: true } }> {
await this.service.updateQuestion(id, body);
return { success: true, data: { success: true } };
}
@Delete(":id")
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.deleteQuestion(id);
return { success: true, data: { success: true } };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { QuestionsController } from "./questions.controller.js";
import { QuestionsService } from "./questions.service.js";
@Module({
controllers: [QuestionsController],
providers: [QuestionsService],
exports: [QuestionsService],
})
export class QuestionsModule {}

View File

@@ -0,0 +1,39 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import {
questions,
type Question,
type NewQuestion,
} from "./questions.schema.js";
export class QuestionsRepository {
async findById(id: string): Promise<Question | undefined> {
const [result] = await db
.select()
.from(questions)
.where(eq(questions.id, id))
.limit(1);
return result;
}
async findByKnowledgePointId(knowledgePointId: string): Promise<Question[]> {
return db
.select()
.from(questions)
.where(eq(questions.knowledgePointId, knowledgePointId));
}
async create(data: NewQuestion): Promise<void> {
await db.insert(questions).values(data);
}
async update(id: string, data: Partial<NewQuestion>): Promise<void> {
await db.update(questions).set(data).where(eq(questions.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(questions).where(eq(questions.id, id));
}
}
export const questionsRepository = new QuestionsRepository();

View File

@@ -0,0 +1,23 @@
import {
mysqlTable,
char,
varchar,
text,
int,
timestamp,
} from "drizzle-orm/mysql-core";
export const questions = mysqlTable("content_questions", {
id: char("id", { length: 36 }).notNull().primaryKey(),
knowledgePointId: char("knowledge_point_id", { length: 36 }).notNull(),
type: varchar("type", { length: 50 }).notNull(),
content: text("content").notNull(),
answer: text("answer"),
explanation: text("explanation"),
difficulty: int("difficulty").default(3),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export type Question = typeof questions.$inferSelect;
export type NewQuestion = typeof questions.$inferInsert;

View File

@@ -0,0 +1,83 @@
import { randomUUID } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { questionsRepository } from "./questions.repository.js";
import type { Question } from "./questions.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateQuestionInput {
knowledgePointId: string;
type: string;
content: string;
answer?: string;
explanation?: string;
difficulty?: number;
}
export interface UpdateQuestionInput {
type?: string;
content?: string;
answer?: string;
explanation?: string;
difficulty?: number;
}
const VALID_TYPES = new Set([
"single_choice",
"multiple_choice",
"short_answer",
"essay",
]);
@Injectable()
export class QuestionsService {
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
if (!input.knowledgePointId || !input.type || !input.content) {
throw new ValidationError("knowledgePointId, type, content are required");
}
if (!VALID_TYPES.has(input.type)) {
throw new ValidationError(
`Invalid question type: ${input.type}. Must be one of: ${[...VALID_TYPES].join(", ")}`,
);
}
const id = randomUUID();
await questionsRepository.create({
id,
knowledgePointId: input.knowledgePointId,
type: input.type,
content: input.content,
answer: input.answer,
explanation: input.explanation,
difficulty: input.difficulty,
});
return { id };
}
async getQuestion(id: string): Promise<Question> {
const question = await questionsRepository.findById(id);
if (!question) {
throw new NotFoundError("Question", id);
}
return question;
}
async listByKnowledgePoint(knowledgePointId: string): Promise<Question[]> {
return questionsRepository.findByKnowledgePointId(knowledgePointId);
}
async updateQuestion(id: string, data: UpdateQuestionInput): Promise<void> {
await this.getQuestion(id);
if (data.type && !VALID_TYPES.has(data.type)) {
throw new ValidationError(`Invalid question type: ${data.type}`);
}
await questionsRepository.update(id, data);
}
async deleteQuestion(id: string): Promise<void> {
await this.getQuestion(id);
await questionsRepository.delete(id);
}
}

View File

@@ -1,7 +1,12 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import { ApplicationError } from './application-error.js';
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
@@ -9,10 +14,13 @@ export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例,
// 但 content 服务未引入 @types/express此处按 core-edu 模式不显式标注类型。
const response = ctx.getResponse();
const request = ctx.getRequest();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
const traceId =
(request.headers["x-request-id"] as string | undefined) ?? "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'CONTENT_VALIDATION_ERROR',
message: 'Validation failed',
code: "CONTENT_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'HTTP_ERROR',
code: "HTTP_ERROR",
message,
traceId,
},
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body);
}
private extractHttpMessage(res: string | object, exception: HttpException): string {
if (typeof res === 'string') {
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === 'object' && 'message' in res) {
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}

View File

@@ -1,46 +1,41 @@
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { db } from "../../config/database.js";
const SERVICE_NAME = 'content';
const SERVICE_NAME = "content";
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活不检查依赖。
* - GET /readyzreadiness检查 DB 连接,失败返回 503。
*
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
*/
@Controller()
export class HealthController {
constructor(private readonly dataSource: DataSource) {}
@Get('healthz')
@Get("healthz")
liveness(): { status: string; service: string; timestamp: string } {
return {
status: 'ok',
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
@Get('readyz')
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
@Get("readyz")
async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
}> {
try {
await this.dataSource.query('SELECT 1');
await db.execute(sql`SELECT 1`);
return {
status: 'ok',
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
} catch (error) {
throw new HttpException(
{
status: 'error',
status: "error",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'database unreachable',
error:
error instanceof Error ? error.message : "database unreachable",
},
HttpStatus.SERVICE_UNAVAILABLE,
);

View File

@@ -1,22 +1,6 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
/**
* 健康检查模块。
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
*
* 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`
*
* ```ts
* import { HealthModule } from './shared/health/health.module';
*
* @Module({ imports: [ ..., HealthModule ], ... })
* export class AppModule {}
* ```
*
* DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。
*/
@Module({
controllers: [HealthController],
})

View File

@@ -1,63 +1,24 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { Redis } from 'ioredis';
import type { Producer } from 'kafkajs';
import { Injectable, Logger } from "@nestjs/common";
import { closeDb } from "../../config/database.js";
import { closeNeo4j } from "../../config/neo4j.js";
const SERVICE_NAME = 'content';
const SERVICE_NAME = "content";
/**
* 优雅停机服务。
*
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
*
* 关闭顺序Kafka producer → Redis → DataSource。
* 先停外部消息生产避免新事件,再关缓存,最后关 DB。
* 注content 服务额外使用 S3/MinIO 客户端,其连接由 SDK 内部管理,无需显式关闭。
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
*
* 依赖注入 token 约定(需与各服务 provider 注册一致):
* - DataSource由 `TypeOrmModule.forRoot()` 提供。
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
export class LifecycleService {
private readonly logger = new Logger(LifecycleService.name);
constructor(
private readonly dataSource: DataSource,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
) {}
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
await this.safeDisconnect('redis', () => this.redis.quit());
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
await closeNeo4j();
await closeDb();
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
`shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -1,24 +1,28 @@
import promClient from 'prom-client';
import promClient from "prom-client";
const registry = new promClient.Registry();
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register
registry.setDefaultLabels({ service: 'content' });
registry.setDefaultLabels({ service: "content" });
registry.registerMetric(
new promClient.Counter({
name: 'content_requests_total',
help: 'Total number of content requests',
labelNames: ['method', 'endpoint', 'status'],
name: "content_requests_total",
help: "Total number of content requests",
labelNames: ["method", "endpoint", "status"],
}),
);
registry.registerMetric(
new promClient.Histogram({
name: 'content_request_duration_seconds',
help: 'Content request duration in seconds',
labelNames: ['method', 'endpoint'],
name: "content_request_duration_seconds",
help: "Content request duration in seconds",
labelNames: ["method", "endpoint"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// 自动收集 Node.js 进程级指标CPU/内存/事件循环/GC等
// 这些指标无需业务代码埋点prom-client 自动采集
promClient.collectDefaultMetrics({ register: registry });
export { registry as metricsRegistry };

View File

@@ -1,25 +1,59 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { TextbooksService } from './textbooks.service.js';
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from "@nestjs/common";
import {
TextbooksService,
type CreateTextbookInput,
type UpdateTextbookInput,
} from "./textbooks.service.js";
import type { Textbook } from "./textbooks.schema.js";
@Controller('textbooks')
@Controller("textbooks")
export class TextbooksController {
constructor(private readonly service: TextbooksService) {}
@Post()
async create(@Body() body: unknown) {
const result = await this.service.create(body as any);
async create(
@Body() body: CreateTextbookInput,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.create(body);
return { success: true, data: result };
}
@Get()
async list() {
const result = await this.service.list();
return { success: true, data: result };
async list(): Promise<{ success: true; data: Textbook[] }> {
const data = await this.service.list();
return { success: true, data };
}
@Get(':id')
async getById(@Param('id') id: string) {
const result = await this.service.getById(id);
return { success: true, data: result };
@Get(":id")
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: Textbook }> {
const data = await this.service.getById(id);
return { success: true, data };
}
@Put(":id")
async update(
@Param("id") id: string,
@Body() body: UpdateTextbookInput,
): Promise<{ success: true; data: { success: true } }> {
await this.service.update(id, body);
return { success: true, data: { success: true } };
}
@Delete(":id")
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.delete(id);
return { success: true, data: { success: true } };
}
}

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { TextbooksController } from './textbooks.controller.js';
import { TextbooksService } from './textbooks.service.js';
import { Module } from "@nestjs/common";
import { TextbooksController } from "./textbooks.controller.js";
import { TextbooksService } from "./textbooks.service.js";
@Module({
controllers: [TextbooksController],
providers: [TextbooksService],
exports: [TextbooksService],
})
export class TextbooksModule {}

View File

@@ -1,30 +1,40 @@
import { mysqlTable, varchar, char, timestamp, text, integer } from 'drizzle-orm/mysql-core';
import {
mysqlTable,
varchar,
char,
timestamp,
text,
int,
} from "drizzle-orm/mysql-core";
export const textbooks = mysqlTable('content_textbooks', {
id: char('id', { length: 36 }).notNull().primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
subjectId: char('subject_id', { length: 36 }).notNull(),
gradeId: char('grade_id', { length: 36 }).notNull(),
version: varchar('version', { length: 50 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
export const textbooks = mysqlTable("content_textbooks", {
id: char("id", { length: 36 }).notNull().primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
subjectId: char("subject_id", { length: 36 }).notNull(),
gradeId: char("grade_id", { length: 36 }).notNull(),
version: varchar("version", { length: 50 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export const chapters = mysqlTable('content_chapters', {
id: char('id', { length: 36 }).notNull().primaryKey(),
textbookId: char('textbook_id', { length: 36 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
order: integer('order_num').notNull(),
parentId: char('parent_id', { length: 36 }),
export const chapters = mysqlTable("content_chapters", {
id: char("id", { length: 36 }).notNull().primaryKey(),
textbookId: char("textbook_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
order: int("order_num").notNull(),
parentId: char("parent_id", { length: 36 }),
});
export const knowledgePoints = mysqlTable('content_knowledge_points', {
id: char('id', { length: 36 }).notNull().primaryKey(),
chapterId: char('chapter_id', { length: 36 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
export const knowledgePoints = mysqlTable("content_knowledge_points", {
id: char("id", { length: 36 }).notNull().primaryKey(),
chapterId: char("chapter_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
description: text("description"),
});
export type Textbook = typeof textbooks.$inferSelect;
export type NewTextbook = typeof textbooks.$inferInsert;
export type Chapter = typeof chapters.$inferSelect;
export type NewChapter = typeof chapters.$inferInsert;
export type KnowledgePoint = typeof knowledgePoints.$inferSelect;
export type NewKnowledgePoint = typeof knowledgePoints.$inferInsert;

View File

@@ -1,69 +1,70 @@
import { Injectable } from '@nestjs/common';
import { getDb } from '../config/database.js';
import { neo4jDriver } from '../config/neo4j.js';
import { textbooks, chapters, knowledgePoints } from './textbooks.schema.js';
import { v4 as uuidv4 } from 'uuid';
import { eq } from 'drizzle-orm';
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { textbooks, type Textbook } from "./textbooks.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateTextbookInput {
title: string;
subjectId: string;
gradeId: string;
version: string;
}
export interface UpdateTextbookInput {
title?: string;
subjectId?: string;
gradeId?: string;
version?: string;
}
@Injectable()
export class TextbooksService {
async create(data: { title: string; subjectId: string; gradeId: string; version: string }) {
const id = uuidv4();
const db = getDb();
await db.insert(textbooks).values({ id, ...data });
return { id, ...data };
async create(input: CreateTextbookInput): Promise<{ id: string }> {
if (!input.title || !input.subjectId || !input.gradeId || !input.version) {
throw new ValidationError(
"title, subjectId, gradeId, version are required",
);
}
const id = randomUUID();
await db.insert(textbooks).values({
id,
title: input.title,
subjectId: input.subjectId,
gradeId: input.gradeId,
version: input.version,
});
return { id };
}
async list() {
const db = getDb();
async list(): Promise<Textbook[]> {
return db.select().from(textbooks);
}
async getById(id: string) {
const db = getDb();
const [result] = await db.select().from(textbooks).where(eq(textbooks.id, id));
async getById(id: string): Promise<Textbook> {
const [result] = await db
.select()
.from(textbooks)
.where(eq(textbooks.id, id))
.limit(1);
if (!result) {
throw new NotFoundError("Textbook", id);
}
return result;
}
// 知识图谱:在 Neo4j 中创建知识点节点和关系
async createKnowledgeGraph(knowledgePointId: string, title: string, prerequisiteIds: string[]): Promise<void> {
const session = neo4jDriver.session();
try {
await session.executeWrite((tx) =>
tx.run(
'MERGE (kp:KnowledgePoint {id: $id, title: $title})',
{ id: knowledgePointId, title }
)
);
for (const prereqId of prerequisiteIds) {
await session.executeWrite((tx) =>
tx.run(
`MATCH (prereq:KnowledgePoint {id: $prereqId}), (kp:KnowledgePoint {id: $kpId})
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
{ prereqId, kpId: knowledgePointId }
)
);
}
} finally {
await session.close();
}
async update(id: string, data: UpdateTextbookInput): Promise<void> {
await this.getById(id);
await db.update(textbooks).set(data).where(eq(textbooks.id, id));
}
// 查询知识图谱(前置知识点)
async getPrerequisites(knowledgePointId: string): Promise<unknown[]> {
const session = neo4jDriver.session();
try {
const result = await session.executeRead((tx) =>
tx.run(
`MATCH (kp:KnowledgePoint {id: $id})<-[:PREREQUISITE_OF*1..5]-(prereq)
RETURN prereq.id as id, prereq.title as title`,
{ id: knowledgePointId }
)
);
return result.records.map((r) => ({ id: r.get('id'), title: r.get('title') }));
} finally {
await session.close();
}
async delete(id: string): Promise<void> {
await this.getById(id);
await db.delete(textbooks).where(eq(textbooks.id, id));
}
}

View File

@@ -8,7 +8,7 @@
"build": "nest build",
"start": "node dist/main.js",
"test": "vitest run",
"lint": "eslint src --ext .ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -1,15 +1,10 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ExamsModule } from './exams/exams.module.js';
import { HomeworkModule } from './homework/homework.module.js';
import { GradesModule } from './grades/grades.module.js';
import { ClassesModule } from './classes/classes.module.js';
import { AuthMiddleware } from './middleware/auth.middleware.js';
import { Module } from "@nestjs/common";
import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js";
import { GradesModule } from "./grades/grades.module.js";
import { HealthModule } from "./shared/health/health.module.js";
@Module({
imports: [ExamsModule, HomeworkModule, GradesModule, ClassesModule],
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(AuthMiddleware).forRoutes('/api/*');
}
}
export class AppModule {}

View File

@@ -1,24 +1,16 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from './env.js';
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
const pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
export function getDb() {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return drizzle(pool);
}
export const db = drizzle(pool);
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
await pool.end();
}

View File

@@ -1,15 +1,20 @@
import { z } from 'zod';
import { z } from "zod";
const envSchema = z.object({
PORT: z.string().default('3004'),
PORT: z.string().default("3004"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string(),
JWT_ISSUER: z.string().default('next-edu-cloud'),
KAFKA_BROKERS: z.string().default('localhost:9092'),
JWT_SECRET: z.string().optional(),
JWT_ISSUER: z.string().default("next-edu-cloud"),
KAFKA_BROKERS: z.string().default("localhost:9092"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
.default("info"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
DEV_MODE: z.string().optional().default("false"),
});
export type Env = z.infer<typeof envSchema>;
@@ -17,8 +22,11 @@ export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment configuration');
console.error(
"❌ Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment configuration");
}
return result.data;
}

View File

@@ -1,22 +1,29 @@
import { Kafka } from 'kafkajs';
import { env } from './env.js';
import { Kafka } from "kafkajs";
import { env } from "./env.js";
export const kafka = new Kafka({
brokers: env.KAFKA_BROKERS.split(','),
clientId: 'core-edu-service',
brokers: env.KAFKA_BROKERS.split(","),
clientId: "core-edu-service",
});
export const producer = kafka.producer({
idempotent: true,
transactionalId: 'core-edu-tx',
transactionalId: "core-edu-tx",
});
export const consumer = kafka.consumer({ groupId: 'core-edu-group' });
export const consumer = kafka.consumer({ groupId: "core-edu-group" });
export async function connectKafka(): Promise<void> {
await producer.connect();
await consumer.connect();
console.log('Kafka connected');
try {
await producer.connect();
await consumer.connect();
console.log("Kafka connected");
} catch (err) {
console.warn(
"Kafka connect failed, running without Kafka:",
err instanceof Error ? err.message : String(err),
);
}
}
export async function disconnectKafka(): Promise<void> {

View File

@@ -6,52 +6,64 @@ import {
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ExamsService, type CreateExamInput, type UpdateExamInput } from './exams.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
Req,
} from "@nestjs/common";
import type { Request } from "express";
import {
ExamsService,
type CreateExamInput,
type UpdateExamInput,
} from "./exams.service.js";
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/exams')
@Controller("exams")
export class ExamsController {
constructor(private readonly examsService: ExamsService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.EXAM_CREATE))
async create(@Body() body: CreateExamInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.examsService.createExam(body);
return { data: result, timestamp: new Date().toISOString() };
async create(
@Body() body: CreateExamInput,
@Req() req: Request,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const result = await this.examsService.createExam({
...body,
createdBy: userId,
});
return { success: true, data: result };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['getExam']>>>> {
@Get(":id")
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["getExam"]>>;
}> {
const data = await this.examsService.getExam(id);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('class/:classId')
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['listExamsByClass']>>>> {
@Get("class/:classId")
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
}> {
const data = await this.examsService.listExamsByClass(classId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Put(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_UPDATE))
async update(@Param('id') id: string, @Body() body: UpdateExamInput): Promise<SuccessResponse<{ success: true }>> {
@Put(":id")
async update(
@Param("id") id: string,
@Body() body: UpdateExamInput,
): Promise<{ success: true; data: { success: true } }> {
await this.examsService.updateExam(id, body);
return { data: { success: true }, timestamp: new Date().toISOString() };
return { success: true, data: { success: true } };
}
@Delete(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_DELETE))
async remove(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
@Delete(":id")
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.examsService.deleteExam(id);
return { data: { success: true }, timestamp: new Date().toISOString() };
return { success: true, data: { success: true } };
}
}

View File

@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { exams, type Exam, type NewExam } from './exams.schema.js';
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import { exams, type Exam, type NewExam } from "./exams.schema.js";
export class ExamsRepository {
async findById(id: string): Promise<Exam | undefined> {

View File

@@ -1,18 +1,21 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { exams } from './exams.schema.js';
import { examsRepository } from './exams.repository.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Exam, NewExam } from './exams.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { exams } from "./exams.schema.js";
import { examsRepository } from "./exams.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Exam, NewExam } from "./exams.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateExamInput {
classId: string;
title: string;
description?: string;
examDate: Date;
examDate: Date | string;
duration: string;
totalScore: string;
createdBy: string;
@@ -31,7 +34,7 @@ export interface UpdateExamInput {
export class ExamsService {
async createExam(input: CreateExamInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError('classId, title, createdBy are required');
throw new ValidationError("classId, title, createdBy are required");
}
const id = randomUUID();
@@ -40,10 +43,15 @@ export class ExamsService {
classId: input.classId,
title: input.title,
description: input.description,
examDate: input.examDate,
// Drizzle datetime 列需要 Date 对象(调用 toISOString。HTTP 请求体里的
// examDate 是 ISO 字符串,这里统一转成 Date避免 "toISOString is not a function"。
examDate:
input.examDate instanceof Date
? input.examDate
: new Date(input.examDate),
duration: input.duration,
totalScore: input.totalScore,
status: 'draft',
status: "draft",
createdBy: input.createdBy,
};
@@ -53,14 +61,14 @@ export class ExamsService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.created',
aggregateType: "exam",
eventType: "exam.created",
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
status: 'pending',
status: "pending",
},
tx,
);
@@ -93,10 +101,10 @@ export class ExamsService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.updated',
aggregateType: "exam",
eventType: "exam.updated",
payload: JSON.stringify({ id, changes: data }),
status: 'pending',
status: "pending",
},
tx,
);
@@ -115,10 +123,10 @@ export class ExamsService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.deleted',
aggregateType: "exam",
eventType: "exam.deleted",
payload: JSON.stringify({ id }),
status: 'pending',
status: "pending",
},
tx,
);

View File

@@ -1,55 +1,57 @@
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { GradesService, type RecordGradeInput } from './grades.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { GradesService, type RecordGradeInput } from "./grades.service.js";
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/grades')
@Controller("grades")
export class GradesController {
constructor(private readonly gradesService: GradesService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.GRADE_CREATE))
async record(@Body() body: RecordGradeInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.gradesService.recordGrade(body);
return { data: result, timestamp: new Date().toISOString() };
async record(
@Body() body: RecordGradeInput,
@Req() req: Request,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const result = await this.gradesService.recordGrade({
...body,
gradedBy: userId,
});
return { success: true, data: result };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['getGrade']>>>> {
@Get(":id")
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["getGrade"]>>;
}> {
const data = await this.gradesService.getGrade(id);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('student/:studentId')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async listByStudent(@Param('studentId') studentId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByStudent']>>>> {
@Get("student/:studentId")
async listByStudent(@Param("studentId") studentId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByStudent"]>>;
}> {
const data = await this.gradesService.listByStudent(studentId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('exam/:examId')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async listByExam(@Param('examId') examId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByExam']>>>> {
@Get("exam/:examId")
async listByExam(@Param("examId") examId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByExam"]>>;
}> {
const data = await this.gradesService.listByExam(examId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('homework/:homeworkId')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async listByHomework(@Param('homeworkId') homeworkId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByHomework']>>>> {
@Get("homework/:homeworkId")
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByHomework"]>>;
}> {
const data = await this.gradesService.listByHomework(homeworkId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
}

View File

@@ -1,11 +1,14 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { grades } from './grades.schema.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Grade, NewGrade } from './grades.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { grades } from "./grades.schema.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Grade, NewGrade } from "./grades.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface RecordGradeInput {
studentId: string;
@@ -20,10 +23,10 @@ export interface RecordGradeInput {
export class GradesService {
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
if (!input.studentId || !input.score || !input.gradedBy) {
throw new ValidationError('studentId, score, gradedBy are required');
throw new ValidationError("studentId, score, gradedBy are required");
}
if (!input.examId && !input.homeworkId) {
throw new ValidationError('Either examId or homeworkId must be provided');
throw new ValidationError("Either examId or homeworkId must be provided");
}
const id = randomUUID();
@@ -43,8 +46,8 @@ export class GradesService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'grade',
eventType: 'grade.recorded',
aggregateType: "grade",
eventType: "grade.recorded",
payload: JSON.stringify({
id,
studentId: input.studentId,
@@ -52,7 +55,7 @@ export class GradesService {
examId: input.examId,
homeworkId: input.homeworkId,
}),
status: 'pending',
status: "pending",
},
tx,
);

View File

@@ -1,48 +1,50 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { HomeworkService, type AssignHomeworkInput } from './homework.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
HomeworkService,
type AssignHomeworkInput,
} from "./homework.service.js";
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/homework')
@Controller("homework")
export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_CREATE))
async assign(@Body() body: AssignHomeworkInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.homeworkService.assignHomework(body);
return { data: result, timestamp: new Date().toISOString() };
async assign(
@Body() body: AssignHomeworkInput,
@Req() req: Request,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const result = await this.homeworkService.assignHomework({
...body,
createdBy: userId,
});
return { success: true, data: result };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['getHomework']>>>> {
@Get(":id")
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
}> {
const data = await this.homeworkService.getHomework(id);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('class/:classId')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['listByClass']>>>> {
@Get("class/:classId")
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
}> {
const data = await this.homeworkService.listByClass(classId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Post(':id/submit')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_SUBMIT))
async submit(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
@Post(":id/submit")
async submit(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.homeworkService.submitHomework(id);
return { data: { success: true }, timestamp: new Date().toISOString() };
return { success: true, data: { success: true } };
}
}

View File

@@ -1,17 +1,20 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { homework } from './homework.schema.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Homework, NewHomework } from './homework.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { homework } from "./homework.schema.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Homework, NewHomework } from "./homework.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface AssignHomeworkInput {
classId: string;
title: string;
description?: string;
dueDate: Date;
dueDate: Date | string;
createdBy: string;
}
@@ -19,7 +22,7 @@ export interface AssignHomeworkInput {
export class HomeworkService {
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError('classId, title, createdBy are required');
throw new ValidationError("classId, title, createdBy are required");
}
const id = randomUUID();
@@ -28,8 +31,10 @@ export class HomeworkService {
classId: input.classId,
title: input.title,
description: input.description,
dueDate: input.dueDate,
status: 'assigned',
// Drizzle datetime 列需要 Date 对象HTTP 请求体里 dueDate 是 ISO 字符串。
dueDate:
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
status: "assigned",
createdBy: input.createdBy,
};
@@ -39,14 +44,14 @@ export class HomeworkService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'homework',
eventType: 'homework.assigned',
aggregateType: "homework",
eventType: "homework.assigned",
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
status: 'pending',
status: "pending",
},
tx,
);
@@ -73,23 +78,23 @@ export class HomeworkService {
async submitHomework(id: string): Promise<void> {
const existing = await this.getHomework(id);
if (existing.status === 'submitted') {
if (existing.status === "submitted") {
throw new ValidationError(`Homework ${id} already submitted`);
}
await db.transaction(async (tx) => {
await tx
.update(homework)
.set({ status: 'submitted' })
.set({ status: "submitted" })
.where(eq(homework.id, id));
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'homework',
eventType: 'homework.submitted',
aggregateType: "homework",
eventType: "homework.submitted",
payload: JSON.stringify({ id }),
status: 'pending',
status: "pending",
},
tx,
);

View File

@@ -1,22 +1,31 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { env } from './config/env.js';
import { connectKafka, disconnectKafka } from './config/kafka.js';
import { outboxPublisher } from './shared/outbox/outbox.publisher.js';
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
import { logger } from './shared/observability/logger.js';
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { env } from "./config/env.js";
import { connectKafka, disconnectKafka } from "./config/kafka.js";
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js";
import { registry } from "./shared/observability/metrics.js";
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.setGlobalPrefix('api');
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Connect Kafka producer/consumer before starting the outbox publisher
await connectKafka();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", registry.contentType);
res.end(await registry.metrics());
});
// Connect Kafka producer/consumer before starting the outbox publisher.
// Non-blocking: if Kafka is unavailable, service still starts; outbox
// publisher will retry sends and messages stay pending until Kafka recovers.
void connectKafka();
// Start the transactional outbox publisher - polls pending messages
// and publishes them to Kafka topics defined in TOPIC_MAP.
@@ -24,12 +33,12 @@ async function bootstrap(): Promise<void> {
await app.listen(env.PORT);
logger.info(
{ port: env.PORT, service: 'core-edu' },
'CoreEdu service is listening',
{ port: env.PORT, service: "core-edu" },
"CoreEdu service is listening",
);
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully...");
await outboxPublisher.stop();
await disconnectKafka();
await shutdownTracer();
@@ -37,8 +46,8 @@ async function bootstrap(): Promise<void> {
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT received, shutting down gracefully...');
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully...");
await outboxPublisher.stop();
await disconnectKafka();
await shutdownTracer();

View File

@@ -1,46 +1,41 @@
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { db } from "../../config/database.js";
const SERVICE_NAME = 'core-edu';
const SERVICE_NAME = "core-edu";
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活不检查依赖。
* - GET /readyzreadiness检查 DB 连接,失败返回 503。
*
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
*/
@Controller()
export class HealthController {
constructor(private readonly dataSource: DataSource) {}
@Get('healthz')
@Get("healthz")
liveness(): { status: string; service: string; timestamp: string } {
return {
status: 'ok',
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
@Get('readyz')
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
@Get("readyz")
async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
}> {
try {
await this.dataSource.query('SELECT 1');
await db.execute(sql`SELECT 1`);
return {
status: 'ok',
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
} catch (error) {
throw new HttpException(
{
status: 'error',
status: "error",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'database unreachable',
error:
error instanceof Error ? error.message : "database unreachable",
},
HttpStatus.SERVICE_UNAVAILABLE,
);

View File

@@ -1,22 +1,6 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
/**
* 健康检查模块。
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
*
* 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`
*
* ```ts
* import { HealthModule } from './shared/health/health.module';
*
* @Module({ imports: [ ..., HealthModule ], ... })
* export class AppModule {}
* ```
*
* DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。
*/
@Module({
controllers: [HealthController],
})

View File

@@ -1,62 +1,22 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { Redis } from 'ioredis';
import type { Producer } from 'kafkajs';
import { Injectable, Logger } from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = 'core-edu';
const SERVICE_NAME = "core-edu";
/**
* 优雅停机服务。
*
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
*
* 关闭顺序Kafka producer → Redis → DataSource。
* 先停外部消息生产避免新事件,再关缓存,最后关 DB。
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
*
* 依赖注入 token 约定(需与各服务 provider 注册一致):
* - DataSource由 `TypeOrmModule.forRoot()` 提供。
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
export class LifecycleService {
private readonly logger = new Logger(LifecycleService.name);
constructor(
private readonly dataSource: DataSource,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
) {}
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
await this.safeDisconnect('redis', () => this.redis.quit());
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
await closeDb();
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
`shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -11,9 +11,12 @@ dependencies = [
"pydantic-settings>=2.5.0",
"opentelemetry-api>=1.27.0",
"opentelemetry-sdk>=1.27.0",
"opentelemetry-exporter-otlp>=1.27.0",
"opentelemetry-instrumentation-fastapi>=0.48b0",
"prometheus-client>=0.20.0",
"structlog>=24.4.0",
# CDC 链路:消费 Debezium 写入 Kafka 的 MySQL binlog 变更事件
"aiokafka>=0.11.0",
]
[tool.ruff]

View File

@@ -0,0 +1,233 @@
"""CDC 消费者Debezium MySQL binlog → ClickHouse 宽表).
链路:
MySQL binlog → Debezium Connect → Kafka topic
edu-cdc.next_edu_cloud.<table>
→ 本消费者 → 解析 Debezium 事件 → 写入 ClickHouse student_dashboard_view
设计要点:
- 监听多张表,按 source.table 路由
- 内存缓存 exam_id → (class_id, subject_id) 映射(来自 core_edu_exams 快照+流)
- 监听 core_edu_grades 时用缓存扩展为宽表记录写入 ClickHouse
- 幂等性:依赖 ClickHouse ReplacingMergeTree 引擎按 ORDER BY 去重
schema 需用 ReplacingMergeTree(last_updated),当前为简化版 MergeTree
- op 类型r(快照读)、c(新增)、u(更新)、d(删除)d 时 after 为 null
"""
import asyncio
import contextlib
import json
from datetime import UTC, datetime
from typing import Any
import structlog
from .clickhouse_client import upsert_student_dashboard
from .config import settings
logger = structlog.get_logger(__name__)
def _parse_ts(ts_ms: int | None) -> datetime:
"""Debezium ts_ms毫秒→ datetime."""
if ts_ms is None:
return datetime.now(UTC)
return datetime.fromtimestamp(ts_ms / 1000, tz=UTC)
def _safe_float(value: Any) -> float:
"""安全转 floatDebezium 数值字段可能是字符串)."""
if value is None:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
class ExamCache:
"""内存缓存 exam_id → (class_id, subject_id).
从 core_edu_exams 表的 CDC 事件构建。subject_id 在 exams 表中暂无字段,
这里占位为空字符串,后续扩展 schema 时再补充。
"""
def __init__(self) -> None:
self._data: dict[str, tuple[str, str]] = {}
def upsert(self, exam_id: str, class_id: str, subject_id: str = "") -> None:
if exam_id:
self._data[exam_id] = (class_id, subject_id)
def get(self, exam_id: str) -> tuple[str, str] | None:
return self._data.get(exam_id) if exam_id else None
# 全局缓存(进程级单例)
_exam_cache = ExamCache()
async def _handle_exams_event(after: dict[str, Any] | None) -> None:
"""处理 core_edu_exams 表事件."""
if after is None:
return
exam_id = after.get("id")
class_id = after.get("class_id", "")
if exam_id:
_exam_cache.upsert(exam_id, class_id)
logger.info("exam_cache_updated", exam_id=exam_id, class_id=class_id)
async def _handle_grades_event(
after: dict[str, Any] | None,
op: str,
ts_ms: int | None,
) -> None:
"""处理 core_edu_grades 表事件 → 写入 ClickHouse 宽表.
- op=r/c/uafter 为新数据,写入宽表
- op=dafter 为 null暂不处理宽表保留历史记录
"""
if after is None:
return
student_id = after.get("student_id", "")
exam_id = after.get("exam_id", "")
score = _safe_float(after.get("score"))
# 从缓存拿 class_id
class_id = ""
if exam_id:
cached = _exam_cache.get(exam_id)
if cached:
class_id = cached[0]
last_updated = _parse_ts(ts_ms)
if after.get("updated_at"):
# 优先用 MySQL 的 updated_at 字段
with contextlib.suppress(ValueError, AttributeError):
last_updated = datetime.fromisoformat(after["updated_at"].replace("Z", "+00:00"))
# 简化rank/kp/mastery/error_count 暂用默认值
# 真实场景应通过其他 CDC 事件或聚合计算得到
await upsert_student_dashboard(
student_id=student_id,
class_id=class_id,
exam_id=exam_id,
subject_id="", # 占位
score=score,
rank_in_class=0,
knowledge_point_id="", # 占位
mastery_level=score / 100.0, # 简化:用分数百分比作为掌握度
error_count=0,
last_updated=last_updated,
)
async def _process_message(topic: str, value: bytes | str) -> None:
"""处理单条 Kafka 消息.
Debezium 事件格式简化后schemas.enable=false
{
"before": {...} | null,
"after": {...} | null,
"source": {"table": "...", "db": "...", ...},
"op": "r|c|u|d",
"ts_ms": 1783572350928
}
"""
try:
value_str = value.decode("utf-8") if isinstance(value, bytes) else value
event = json.loads(value_str)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("cdc_message_decode_failed", error=str(exc), topic=topic)
return
source = event.get("source") or {}
table = source.get("table", "")
op = event.get("op", "")
ts_ms = event.get("ts_ms")
after = event.get("after")
logger.info(
"cdc_event_received",
topic=topic,
table=table,
op=op,
ts_ms=ts_ms,
)
if table == "core_edu_exams":
await _handle_exams_event(after)
elif table == "core_edu_grades":
await _handle_grades_event(after, op, ts_ms)
else:
# 其他表暂不处理,仅记录
logger.debug("cdc_event_skipped", table=table)
async def run_consumer() -> None:
"""CDC 消费者主循环lifespan 启动).
- kafka_brokers 未配置:直接返回,不启动消费者(降级模式)
- 启动失败:仅记录错误,不阻塞 FastAPI 主流程
"""
if not settings.kafka_brokers:
logger.info("cdc_consumer_disabled_no_kafka_brokers")
return
try:
from aiokafka import AIOKafkaConsumer
except ImportError:
logger.warning("cdc_consumer_aiokafka_not_installed")
return
topics = [t.strip() for t in settings.kafka_cdc_topics.split(",") if t.strip()]
if not topics:
logger.warning("cdc_consumer_no_topics_configured")
return
brokers = [b.strip() for b in settings.kafka_brokers.split(",") if b.strip()]
consumer = AIOKafkaConsumer(
*topics,
bootstrap_servers=brokers,
group_id=settings.kafka_group_id,
auto_offset_reset=settings.kafka_auto_offset_reset,
enable_auto_commit=True,
value_deserializer=lambda v: v, # 保留原始 bytes由 _process_message 解码
)
try:
await consumer.start()
logger.info(
"cdc_consumer_started",
brokers=brokers,
topics=topics,
group_id=settings.kafka_group_id,
)
except Exception as exc: # noqa: BLE001
logger.error("cdc_consumer_start_failed", error=str(exc))
return
try:
async for msg in consumer:
try:
await _process_message(msg.topic, msg.value)
except Exception as exc: # noqa: BLE001
logger.error(
"cdc_message_process_failed",
error=str(exc),
topic=msg.topic,
partition=msg.partition,
offset=msg.offset,
)
except asyncio.CancelledError:
logger.info("cdc_consumer_cancelled")
raise
finally:
try:
await consumer.stop()
logger.info("cdc_consumer_stopped")
except Exception as exc: # noqa: BLE001
logger.warning("cdc_consumer_stop_failed", error=str(exc))

View File

@@ -1,27 +1,343 @@
"""ClickHouse 客户端."""
"""ClickHouse 客户端(支持降级模式).
import clickhouse_connect
当 settings.clickhouse_host 为空字符串时get_client() 返回 None
查询方法在 client 为 None 或查询失败时返回 None降级模式
保证服务在 ClickHouse 不可用时仍可启动并响应骨架数据。
"""
from datetime import datetime
from typing import Any
import structlog
from .config import settings
_client = None
logger = structlog.get_logger(__name__)
_client: Any | None = None
# 标记是否已尝试初始化(避免对失败连接反复重试)
_client_initialized: bool = False
def get_client():
"""获取 ClickHouse 客户端."""
global _client
if _client is None:
_client = clickhouse_connect.get_client(
def get_client() -> Any | None:
"""获取 ClickHouse 客户端.
- 当 clickhouse_host 为空:返回 None降级模式
- 当已初始化但失败:返回 None
- 当 clickhouse_connect 未安装:返回 None
"""
global _client, _client_initialized
if not settings.clickhouse_host:
# 未配置 ClickHouse降级模式
return None
if _client_initialized:
return _client
_client_initialized = True
try:
import clickhouse_connect
kwargs: dict[str, Any] = {
"host": settings.clickhouse_host,
"port": settings.clickhouse_port,
"database": settings.clickhouse_database,
}
if settings.clickhouse_user:
kwargs["username"] = settings.clickhouse_user
if settings.clickhouse_password:
kwargs["password"] = settings.clickhouse_password
_client = clickhouse_connect.get_client(**kwargs)
logger.info(
"clickhouse_client_initialized",
host=settings.clickhouse_host,
port=settings.clickhouse_port,
database=settings.clickhouse_database,
)
except Exception as exc: # noqa: BLE001
# 任何初始化异常都进入降级模式,不抛出
logger.warning("clickhouse_client_init_failed_degraded", error=str(exc))
_client = None
return _client
async def close_client() -> None:
"""关闭客户端."""
global _client
if _client:
_client.close()
_client = None
global _client, _client_initialized
if _client is not None:
try:
_client.close()
except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_client_close_failed", error=str(exc))
finally:
_client = None
_client_initialized = False
async def query_dashboard(student_id: str) -> dict | None:
"""查询学生学情看板(宽表 student_dashboard_view.
返回 None 表示降级模式ClickHouse 不可用或查询失败)。
"""
client = get_client()
if client is None:
return None
try:
rows = client.query(
"SELECT student_id, class_id, exam_id, subject_id, score, "
"rank_in_class, knowledge_point_id, mastery_level, error_count, "
"last_updated "
"FROM student_dashboard_view "
"WHERE student_id = {sid:String} "
"ORDER BY last_updated DESC "
"LIMIT 50",
parameters={"sid": student_id},
).result_rows
except Exception as exc: # noqa: BLE001
logger.warning("query_dashboard_failed_degraded", error=str(exc), student_id=student_id)
return None
columns = [
"student_id",
"class_id",
"exam_id",
"subject_id",
"score",
"rank_in_class",
"knowledge_point_id",
"mastery_level",
"error_count",
"last_updated",
]
records = [dict(zip(columns, row, strict=True)) for row in rows]
return {
"studentId": student_id,
"records": records,
"total": len(records),
}
async def query_class_performance(class_id: str) -> dict | None:
"""查询班级成绩分析(聚合 student_dashboard_view.
返回 None 表示降级模式。
"""
client = get_client()
if client is None:
return None
try:
# 平均分、参考人数、及格率(>=60
agg_rows = client.query(
"SELECT "
" count() AS total_students, "
" avg(score) AS average_score, "
" countIf(score >= 60) / count() AS pass_rate "
"FROM student_dashboard_view "
"WHERE class_id = {cid:String}",
parameters={"cid": class_id},
).result_rows
except Exception as exc: # noqa: BLE001
logger.warning(
"query_class_performance_failed_degraded",
error=str(exc),
class_id=class_id,
)
return None
if not agg_rows:
return {
"classId": class_id,
"averageScore": 0.0,
"passRate": 0.0,
"totalStudents": 0,
}
total_students, average_score, pass_rate = agg_rows[0]
return {
"classId": class_id,
"averageScore": float(average_score) if average_score is not None else 0.0,
"passRate": float(pass_rate) if pass_rate is not None else 0.0,
"totalStudents": int(total_students) if total_students is not None else 0,
}
async def query_student_errors(student_id: str) -> list[dict] | None:
"""查询学生错题本(表 student_errors.
返回 None 表示降级模式;返回空列表表示无错题数据。
"""
client = get_client()
if client is None:
return None
try:
rows = client.query(
"SELECT student_id, question_id, knowledge_point_id, error_count, "
"last_error_time, content "
"FROM student_errors "
"WHERE student_id = {sid:String} "
"ORDER BY last_error_time DESC "
"LIMIT 100",
parameters={"sid": student_id},
).result_rows
except Exception as exc: # noqa: BLE001
logger.warning(
"query_student_errors_failed_degraded",
error=str(exc),
student_id=student_id,
)
return None
columns = [
"student_id",
"question_id",
"knowledge_point_id",
"error_count",
"last_error_time",
"content",
]
return [dict(zip(columns, row, strict=True)) for row in rows]
async def ping() -> bool:
"""ClickHouse 连通性检查(供 /readyz 使用).
返回 True 表示可用False 表示未配置或不可用。
"""
client = get_client()
if client is None:
return False
try:
client.query("SELECT 1")
return True
except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_ping_failed", error=str(exc))
return False
async def upsert_student_dashboard(
student_id: str,
class_id: str,
exam_id: str,
subject_id: str,
score: float,
rank_in_class: int,
knowledge_point_id: str,
mastery_level: float,
error_count: int,
last_updated: datetime,
) -> bool:
"""写入/更新学生学情宽表CDC 消费专用).
使用 ReplacingMergeTree 语义:按 ORDER BY 字段去重,保留 last_updated 最大版本。
返回 True 表示成功False 表示降级模式或写入失败。
"""
client = get_client()
if client is None:
return False
try:
client.insert(
"student_dashboard_view",
[
[
student_id,
class_id,
exam_id,
subject_id,
score,
rank_in_class,
knowledge_point_id,
mastery_level,
error_count,
last_updated,
]
],
column_names=[
"student_id",
"class_id",
"exam_id",
"subject_id",
"score",
"rank_in_class",
"knowledge_point_id",
"mastery_level",
"error_count",
"last_updated",
],
)
logger.info(
"student_dashboard_upserted",
student_id=student_id,
class_id=class_id,
exam_id=exam_id,
score=score,
)
return True
except Exception as exc: # noqa: BLE001
logger.warning(
"student_dashboard_upsert_failed_degraded",
error=str(exc),
student_id=student_id,
exam_id=exam_id,
)
return False
async def upsert_student_error(
student_id: str,
question_id: str,
knowledge_point_id: str,
error_count: int,
last_error_time: datetime,
content: str,
) -> bool:
"""写入/更新学生错题本CDC 消费专用).
返回 True 表示成功False 表示降级模式或写入失败。
"""
client = get_client()
if client is None:
return False
try:
client.insert(
"student_errors",
[
[
student_id,
question_id,
knowledge_point_id,
error_count,
last_error_time,
content,
]
],
column_names=[
"student_id",
"question_id",
"knowledge_point_id",
"error_count",
"last_error_time",
"content",
],
)
logger.info(
"student_error_upserted",
student_id=student_id,
question_id=question_id,
error_count=error_count,
)
return True
except Exception as exc: # noqa: BLE001
logger.warning(
"student_error_upsert_failed_degraded",
error=str(exc),
student_id=student_id,
question_id=question_id,
)
return False

View File

@@ -1,16 +1,44 @@
"""配置管理."""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置."""
"""应用配置.
ClickHouse 连接参数为可选:当 clickhouse_host 为空字符串时,
服务进入降级模式(查询方法返回 None / 空数据),保证服务可启动。
Kafka 连接参数为可选:当 kafka_brokers 为空字符串时,
CDC 消费者不启动(降级模式),保证服务可启动。
"""
port: int = 3006
clickhouse_host: str = "localhost"
# ClickHouse 连接(可选:留空则降级模式)
clickhouse_host: str = ""
clickhouse_port: int = 8123
clickhouse_database: str = "edu_analytics"
clickhouse_user: str = ""
clickhouse_password: str = ""
# 可观测性
otel_endpoint: str = "http://localhost:4318"
log_level: str = "info"
# 开发模式开关("true"/"false"
dev_mode: str = "false"
# Kafka brokersCDC 消费;留空则不启动消费者)
# 主机访问用 localhost:9092容器内访问用 kafka:29092
kafka_brokers: str = ""
# CDC 消费组 id
kafka_group_id: str = "data-ana-cdc-consumer"
# 要消费的 CDC topicDebezium 默认命名:<prefix>.<database>.<table>
# 用逗号分隔多个 topic
kafka_cdc_topics: str = (
"edu-cdc.next_edu_cloud.core_edu_grades,"
"edu-cdc.next_edu_cloud.core_edu_exams,"
"edu-cdc.next_edu_cloud.classes"
)
# 消费者自动偏移重置策略earliest / latest
kafka_auto_offset_reset: str = "earliest"
model_config = {"env_file": ".env", "env_prefix": ""}

View File

@@ -1,6 +1,16 @@
"""数据分析服务入口."""
"""数据分析服务入口.
支持 ClickHouse 降级模式:当 CLICKHOUSE_HOST 未配置或不可达时,
查询端点返回骨架数据,服务仍可启动与响应。
支持 CDC 消费者:当 KAFKA_BROKERS 配置时,
后台启动 aiokafka 消费者,监听 Debezium CDC 事件写入 ClickHouse。
"""
import asyncio
import contextlib
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import structlog
from fastapi import FastAPI
@@ -10,25 +20,104 @@ from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app
logger = structlog.get_logger()
from .cdc_consumer import run_consumer as run_cdc_consumer
from .clickhouse_client import (
close_client,
query_class_performance,
query_dashboard,
query_student_errors,
)
from .clickhouse_client import ping as ch_ping
from .config import settings
_logger: structlog.stdlib.BoundLogger | None = None
tracer = trace.get_tracer(__name__)
# CDC 消费者后台任务句柄
_cdc_task: asyncio.Task | None = None
# 日志级别映射
_LOG_LEVELS: dict[str, int] = {
"DEBUG": 10,
"INFO": 20,
"WARNING": 30,
"ERROR": 40,
"CRITICAL": 50,
}
def init_logger() -> structlog.stdlib.BoundLogger:
"""初始化 structlog logger.
根据配置的 log_level 设置日志级别。
"""
global _logger
level = _LOG_LEVELS.get(settings.log_level.upper(), 20)
structlog.configure(
wrapper_class=structlog.make_filtering_bound_logger(level),
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer(),
],
cache_logger_on_first_use=True,
)
_logger = structlog.get_logger(__name__)
return _logger
def get_logger() -> structlog.stdlib.BoundLogger:
"""获取已初始化的 logger未初始化时自动初始化."""
global _logger
if _logger is None:
return init_logger()
return _logger
def init_tracer() -> None:
"""初始化 OpenTelemetry."""
"""初始化 OpenTelemetry.
endpoint 从 settings.otel_endpoint 读取(不硬编码)。
"""
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
endpoint = settings.otel_endpoint.rstrip("/")
exporter = OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期."""
"""应用生命周期.
1. 初始化 loggerstructlog
2. 初始化 OTel tracerendpoint 从 config 读)
3. 触发 ClickHouse 客户端惰性初始化(不阻塞启动,失败进入降级模式)
4. 若配置了 kafka_brokers后台启动 CDC 消费者任务
5. 关闭时停止 CDC 任务并释放 ClickHouse 客户端
"""
global _cdc_task
logger = init_logger()
init_tracer()
logger.info("data-ana service starting")
logger.info(
"data_ana_service_starting",
port=settings.port,
dev_mode=settings.dev_mode,
clickhouse_configured=bool(settings.clickhouse_host),
kafka_brokers=settings.kafka_brokers,
kafka_cdc_topics=settings.kafka_cdc_topics,
)
# 启动 CDC 消费者后台任务(若未配置 kafka_brokersrun_consumer 内部直接返回)
_cdc_task = asyncio.create_task(run_cdc_consumer())
yield
logger.info("data-ana service stopping")
logger.info("data_ana_service_stopping")
# 取消 CDC 任务
if _cdc_task is not None and not _cdc_task.done():
_cdc_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _cdc_task
await close_client()
app = FastAPI(
@@ -42,36 +131,158 @@ app.mount("/metrics", make_asgi_app())
@app.get("/healthz")
async def healthz():
"""健康检查."""
async def healthz() -> dict:
"""健康检查liveness.
只要进程存活即返回 ok不依赖 ClickHouse。
"""
return {"status": "ok", "service": "data-ana"}
@app.get("/analytics/class/{class_id}/performance")
async def class_performance(class_id: str):
"""班级成绩分析."""
with tracer.start_as_current_span("class_performance"):
# P4 骨架:从 ClickHouse 查询分析数据
@app.get("/readyz")
async def readyz() -> dict:
"""就绪检查readiness.
ClickHouse 为可选依赖:
- 已配置且可达ready=true
- 未配置ready=truedegraded=true降级模式仍可服务
- 已配置但不可达ready=false
CDC 消费者状态附加在响应中:
- cdc_consumer: running / disabled / failed
"""
cdc_status = "disabled"
if _cdc_task is not None:
if _cdc_task.done():
cdc_status = "failed"
elif not settings.kafka_brokers:
cdc_status = "disabled"
else:
cdc_status = "running"
if not settings.clickhouse_host:
return {
"success": True,
"data": {
"classId": class_id,
"averageScore": 0,
"passRate": 0,
"message": "P4 skeleton - ClickHouse integration pending",
},
"status": "ok",
"service": "data-ana",
"ready": True,
"degraded": True,
"clickhouse": "not_configured",
"cdc_consumer": cdc_status,
"kafka_brokers": settings.kafka_brokers or None,
"timestamp": datetime.now(UTC).isoformat(),
}
ch_ok = await ch_ping()
return {
"status": "ok" if ch_ok else "degraded",
"service": "data-ana",
"ready": ch_ok,
"degraded": not ch_ok,
"clickhouse": "ok" if ch_ok else "unreachable",
"cdc_consumer": cdc_status,
"kafka_brokers": settings.kafka_brokers or None,
"timestamp": datetime.now(UTC).isoformat(),
}
@app.get("/analytics/class/{class_id}/performance")
async def class_performance(class_id: str) -> dict:
"""班级成绩分析.
优先查 ClickHouse降级时返回骨架数据。
"""
logger = get_logger()
with tracer.start_as_current_span("class_performance") as span:
span.set_attribute("class_id", class_id)
result = await query_class_performance(class_id)
if result is None:
logger.info("class_performance_degraded", class_id=class_id)
return {
"success": True,
"data": {
"classId": class_id,
"averageScore": 0,
"passRate": 0,
"totalStudents": 0,
"message": "ClickHouse unavailable - skeleton data",
"degraded": True,
},
}
return {"success": True, "data": {**result, "degraded": False}}
@app.get("/analytics/student/{student_id}/weakness")
async def student_weakness(student_id: str):
"""学生薄弱知识点分析."""
with tracer.start_as_current_span("student_weakness"):
async def student_weakness(student_id: str) -> dict:
"""学生薄弱知识点分析.
优先查 ClickHouse降级时返回骨架数据。
"""
logger = get_logger()
with tracer.start_as_current_span("student_weakness") as span:
span.set_attribute("student_id", student_id)
result = await query_dashboard(student_id)
if result is None:
logger.info("student_weakness_degraded", student_id=student_id)
return {
"success": True,
"data": {
"studentId": student_id,
"weakPoints": [],
"message": "ClickHouse unavailable - skeleton data",
"degraded": True,
},
}
# 从宽表提取薄弱知识点mastery_level < 0.6 视为薄弱
weak_points = [
{
"knowledgePointId": r["knowledge_point_id"],
"masteryLevel": r["mastery_level"],
"errorCount": r["error_count"],
}
for r in result["records"]
if r.get("mastery_level") is not None and r["mastery_level"] < 0.6
]
return {
"success": True,
"data": {
"studentId": student_id,
"weakPoints": [],
"message": "P4 skeleton - weakness analysis pending",
"weakPoints": weak_points,
"records": result["records"],
"total": result["total"],
"degraded": False,
},
}
@app.get("/analytics/student/{student_id}/errorbook")
async def student_errorbook(student_id: str) -> dict:
"""学生错题本.
优先查 ClickHouse降级时返回空列表。
"""
logger = get_logger()
with tracer.start_as_current_span("student_errorbook") as span:
span.set_attribute("student_id", student_id)
result = await query_student_errors(student_id)
if result is None:
logger.info("student_errorbook_degraded", student_id=student_id)
return {
"success": True,
"data": {
"studentId": student_id,
"errors": [],
"total": 0,
"message": "ClickHouse unavailable - empty errorbook",
"degraded": True,
},
}
return {
"success": True,
"data": {
"studentId": student_id,
"errors": result,
"total": len(result),
"degraded": False,
},
}

View File

@@ -8,7 +8,7 @@
"build": "nest build",
"start": "node dist/main.js",
"test": "vitest run",
"lint": "eslint src --ext .ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -32,6 +32,7 @@
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",

View File

@@ -1,38 +1,41 @@
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { IamService } from './iam.service.js';
import { registerSchema, loginSchema, refreshTokenSchema } from './iam.dto.js';
import type { AuthenticatedRequest } from '../middleware/auth.middleware.js';
import { Body, Controller, Get, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js";
import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller('iam')
@Controller("iam")
export class IamController {
constructor(private readonly service: IamService) {}
@Post('register')
@Post("register")
async register(@Body() body: unknown) {
const dto = registerSchema.parse(body);
const result = await this.service.register(dto);
return { success: true as const, data: result };
}
@Post('login')
@Post("login")
async login(@Body() body: unknown) {
const dto = loginSchema.parse(body);
const result = await this.service.login(dto);
return { success: true as const, data: result };
}
@Post('refresh')
@Post("refresh")
async refresh(@Body() body: unknown) {
const dto = refreshTokenSchema.parse(body);
const tokens = await this.service.refresh(dto.refreshToken);
return { success: true as const, data: tokens };
}
@Get('me')
@Get("me")
async me(@Req() req: Request) {
const authReq = req as AuthenticatedRequest;
const user = await this.service.getUserInfo(authReq.userId as string);
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const user = await this.service.getUserInfo(userId);
return { success: true as const, data: user };
}
}

View File

@@ -1,13 +1,11 @@
import { Module } from '@nestjs/common';
import { IamController } from './iam.controller.js';
import { IamService } from './iam.service.js';
import { IamRepository } from './iam.repository.js';
import { Module } from "@nestjs/common";
import { IamController } from "./iam.controller.js";
import { RbacController } from "./rbac.controller.js";
import { IamService } from "./iam.service.js";
import { IamRepository } from "./iam.repository.js";
@Module({
controllers: [IamController],
providers: [
IamService,
{ provide: IamRepository, useFactory: () => new IamRepository() },
],
controllers: [IamController, RbacController],
providers: [IamService, IamRepository],
})
export class IamModule {}

View File

@@ -1,19 +1,38 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../config/database.js';
import { users, roles, userRoles, permissions, rolePermissions, refreshTokens } from './iam.schema.js';
import type { User, Role, Permission } from './iam.schema.js';
import { eq, inArray } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
users,
roles,
userRoles,
permissions,
rolePermissions,
refreshTokens,
roleViewports,
} from "./iam.schema.js";
import type { User, Role, Permission, RoleViewport } from "./iam.schema.js";
export class IamRepository {
async createUser(data: { id: string; email: string; passwordHash: string; name: string }): Promise<User> {
async createUser(data: {
id: string;
email: string;
passwordHash: string;
name: string;
}): Promise<User> {
const db = getDb();
await db.insert(users).values(data);
const [result] = await db.select().from(users).where(eq(users.id, data.id));
if (!result) {
throw new Error("Failed to create user");
}
return result;
}
async findUserByEmail(email: string): Promise<User | undefined> {
const db = getDb();
const [result] = await db.select().from(users).where(eq(users.email, email));
const [result] = await db
.select()
.from(users)
.where(eq(users.email, email));
return result;
}
@@ -30,20 +49,59 @@ export class IamRepository {
.from(roles)
.innerJoin(userRoles, eq(roles.id, userRoles.roleId))
.where(eq(userRoles.userId, userId));
return result.map((r) => r.roles);
return result.map((r) => r.iam_roles);
}
async getUserPermissions(userId: string): Promise<Permission[]> {
const db = getDb();
const userRoleRows = await db.select().from(userRoles).where(eq(userRoles.userId, userId));
const userRoleRows = await db
.select()
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleRows.map((r) => r.roleId);
if (roleIds.length === 0) return [];
const result = await db
.select()
.from(permissions)
.innerJoin(rolePermissions, eq(permissions.id, rolePermissions.permissionId))
.where(rolePermissions.roleId.in(roleIds));
return result.map((r) => r.permissions);
.innerJoin(
rolePermissions,
eq(permissions.id, rolePermissions.permissionId),
)
.where(inArray(rolePermissions.roleId, roleIds));
return result.map((r) => r.iam_permissions);
}
async getUserViewports(userId: string): Promise<RoleViewport[]> {
const db = getDb();
const userRoleRows = await db
.select()
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleRows.map((r) => r.roleId);
if (roleIds.length === 0) return [];
return db
.select()
.from(roleViewports)
.where(inArray(roleViewports.roleId, roleIds));
}
async getUserDataScope(userId: string): Promise<string> {
const db = getDb();
const [user] = await db
.select({ dataScope: users.dataScope })
.from(users)
.where(eq(users.id, userId));
return user?.dataScope ?? "self";
}
async getAllRoles(): Promise<Role[]> {
const db = getDb();
return db.select().from(roles);
}
async getAllPermissions(): Promise<Permission[]> {
const db = getDb();
return db.select().from(permissions);
}
async assignRole(userId: string, roleId: string): Promise<void> {
@@ -51,13 +109,21 @@ export class IamRepository {
await db.insert(userRoles).values({ userId, roleId });
}
async createRefreshToken(data: { id: string; userId: string; tokenHash: string; expiresAt: Date }): Promise<void> {
async createRefreshToken(data: {
id: string;
userId: string;
tokenHash: string;
expiresAt: Date;
}): Promise<void> {
const db = getDb();
await db.insert(refreshTokens).values(data);
}
async revokeRefreshToken(id: string): Promise<void> {
const db = getDb();
await db.update(refreshTokens).set({ revokedAt: new Date() }).where(eq(refreshTokens.id, id));
await db
.update(refreshTokens)
.set({ revokedAt: new Date() })
.where(eq(refreshTokens.id, id));
}
}

View File

@@ -1,48 +1,81 @@
import { mysqlTable, varchar, char, timestamp } from 'drizzle-orm/mysql-core';
import {
mysqlTable,
varchar,
char,
timestamp,
text,
mysqlEnum,
} from "drizzle-orm/mysql-core";
export const users = mysqlTable('iam_users', {
id: char('id', { length: 36 }).notNull().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
name: varchar('name', { length: 100 }).notNull(),
status: varchar('status', { length: 20 }).notNull().default('active'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
export const users = mysqlTable("iam_users", {
id: char("id", { length: 36 }).notNull().primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
name: varchar("name", { length: 100 }).notNull(),
status: varchar("status", { length: 20 }).notNull().default("active"),
dataScope: mysqlEnum("data_scope", [
"self",
"class",
"grade",
"school",
"district",
"all",
])
.notNull()
.default("self"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export const roles = mysqlTable('iam_roles', {
id: char('id', { length: 36 }).notNull().primaryKey(),
name: varchar('name', { length: 50 }).notNull().unique(),
description: varchar('description', { length: 255 }),
export const roles = mysqlTable("iam_roles", {
id: char("id", { length: 36 }).notNull().primaryKey(),
name: varchar("name", { length: 50 }).notNull().unique(),
description: varchar("description", { length: 255 }),
});
export const userRoles = mysqlTable('iam_user_roles', {
userId: char('user_id', { length: 36 }).notNull(),
roleId: char('role_id', { length: 36 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
export const userRoles = mysqlTable("iam_user_roles", {
userId: char("user_id", { length: 36 }).notNull(),
roleId: char("role_id", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const permissions = mysqlTable('iam_permissions', {
id: char('id', { length: 36 }).notNull().primaryKey(),
name: varchar('name', { length: 100 }).notNull().unique(),
resource: varchar('resource', { length: 50 }).notNull(),
action: varchar('action', { length: 50 }).notNull(),
export const permissions = mysqlTable("iam_permissions", {
id: char("id", { length: 36 }).notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull().unique(),
resource: varchar("resource", { length: 50 }).notNull(),
action: varchar("action", { length: 50 }).notNull(),
});
export const rolePermissions = mysqlTable('iam_role_permissions', {
roleId: char('role_id', { length: 36 }).notNull(),
permissionId: char('permission_id', { length: 36 }).notNull(),
export const rolePermissions = mysqlTable("iam_role_permissions", {
roleId: char("role_id", { length: 36 }).notNull(),
permissionId: char("permission_id", { length: 36 }).notNull(),
});
export const refreshTokens = mysqlTable('iam_refresh_tokens', {
id: char('id', { length: 36 }).notNull().primaryKey(),
userId: char('user_id', { length: 36 }).notNull(),
tokenHash: varchar('token_hash', { length: 255 }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
revokedAt: timestamp('revoked_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
export const refreshTokens = mysqlTable("iam_refresh_tokens", {
id: char("id", { length: 36 }).notNull().primaryKey(),
userId: char("user_id", { length: 36 }).notNull(),
tokenHash: varchar("token_hash", { length: 255 }).notNull(),
expiresAt: timestamp("expires_at").notNull(),
revokedAt: timestamp("revoked_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// 视口配置表4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据)
// 每条记录代表一个角色可见的导航项
export const roleViewports = mysqlTable("iam_role_viewports", {
id: char("id", { length: 36 }).notNull().primaryKey(),
roleId: char("role_id", { length: 36 }).notNull(),
viewportKey: varchar("viewport_key", { length: 50 }).notNull(),
label: varchar("label", { length: 100 }).notNull(),
route: varchar("route", { length: 200 }).notNull(),
icon: varchar("icon", { length: 50 }),
sortOrder: varchar("sort_order", { length: 10 }).notNull().default("0"),
requiredPermission: varchar("required_permission", { length: 100 }),
// L3 组件级配置JSON控制组件内按钮/操作的显隐
componentConfig: text("component_config"),
});
export type User = typeof users.$inferSelect;
export type Role = typeof roles.$inferSelect;
export type Permission = typeof permissions.$inferSelect;
export type RoleViewport = typeof roleViewports.$inferSelect;

View File

@@ -1,11 +1,16 @@
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { IamRepository } from './iam.repository.js';
import { ConflictError, UnauthorizedError, NotFoundError } from '../shared/errors/application-error.js';
import { env } from '../config/env.js';
import type { RegisterDto, LoginDto } from './iam.dto.js';
import type { User } from './iam.schema.js';
import { v4 as uuidv4 } from "uuid";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Inject } from "@nestjs/common";
import { IamRepository } from "./iam.repository.js";
import {
ConflictError,
UnauthorizedError,
NotFoundError,
} from "../shared/errors/application-error.js";
import { env } from "../config/env.js";
import type { RegisterDto, LoginDto } from "./iam.dto.js";
import type { User, Role, Permission } from "./iam.schema.js";
export interface TokenPair {
accessToken: string;
@@ -19,15 +24,32 @@ export interface UserInfo {
name: string;
roles: string[];
permissions: string[];
dataScope: string;
}
export class IamService {
constructor(private readonly repository: IamRepository) {}
export interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: string;
requiredPermission: string | null;
}
async register(dto: RegisterDto): Promise<{ user: UserInfo; tokens: TokenPair }> {
// teacher 角色固定 ID种子数据
const TEACHER_ROLE_ID = "00000000-0000-0000-0000-000000000001";
export class IamService {
constructor(
@Inject(IamRepository) private readonly repository: IamRepository,
) {}
async register(
dto: RegisterDto,
): Promise<{ user: UserInfo; tokens: TokenPair }> {
const existing = await this.repository.findUserByEmail(dto.email);
if (existing) {
throw new ConflictError('Email already registered');
throw new ConflictError("Email already registered");
}
const userId = uuidv4();
@@ -39,8 +61,8 @@ export class IamService {
name: dto.name,
});
// P2 骨架:默认分配 teacher 角色(实际应通过邀请码注册)
// await this.repository.assignRole(userId, defaultRoleId);
// 默认分配 teacher 角色
await this.repository.assignRole(userId, TEACHER_ROLE_ID);
const { tokens } = await this.issueTokens(user);
const info = await this.buildUserInfo(user);
@@ -50,16 +72,16 @@ export class IamService {
async login(dto: LoginDto): Promise<{ user: UserInfo; tokens: TokenPair }> {
const user = await this.repository.findUserByEmail(dto.email);
if (!user) {
throw new UnauthorizedError('Invalid credentials');
throw new UnauthorizedError("Invalid credentials");
}
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) {
throw new UnauthorizedError('Invalid credentials');
throw new UnauthorizedError("Invalid credentials");
}
if (user.status !== 'active') {
throw new UnauthorizedError('Account is not active');
if (user.status !== "active") {
throw new UnauthorizedError("Account is not active");
}
const { tokens } = await this.issueTokens(user);
@@ -72,16 +94,16 @@ export class IamService {
try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as jwt.JwtPayload;
} catch {
throw new UnauthorizedError('Invalid refresh token');
throw new UnauthorizedError("Invalid refresh token");
}
if (payload.type !== 'refresh') {
throw new UnauthorizedError('Invalid token type');
if (payload.type !== "refresh") {
throw new UnauthorizedError("Invalid token type");
}
const user = await this.repository.findUserById(payload.sub as string);
if (!user) {
throw new NotFoundError('User', payload.sub as string);
throw new NotFoundError("User", payload.sub as string);
}
return this.issueTokens(user).then((r) => r.tokens);
@@ -90,25 +112,68 @@ export class IamService {
async getUserInfo(userId: string): Promise<UserInfo> {
const user = await this.repository.findUserById(userId);
if (!user) {
throw new NotFoundError('User', userId);
throw new NotFoundError("User", userId);
}
return this.buildUserInfo(user);
}
// 有效权限聚合:多角色权限去重
async getEffectivePermissions(userId: string): Promise<string[]> {
const perms = await this.repository.getUserPermissions(userId);
const unique = new Set(perms.map((p) => p.name));
return [...unique];
}
async getUserViewports(userId: string): Promise<ViewportItem[]> {
const viewports = await this.repository.getUserViewports(userId);
const permissions = await this.getEffectivePermissions(userId);
// 过滤:如果视口需要权限且用户不具备,则不返回
return viewports
.filter((vp) => {
if (!vp.requiredPermission) return true;
return permissions.includes(vp.requiredPermission);
})
.map((vp) => ({
key: vp.viewportKey,
label: vp.label,
route: vp.route,
icon: vp.icon,
sortOrder: vp.sortOrder,
requiredPermission: vp.requiredPermission,
}))
.sort((a, b) => a.sortOrder.localeCompare(b.sortOrder));
}
async getAllRoles(): Promise<Role[]> {
return this.repository.getAllRoles();
}
async getAllPermissions(): Promise<Permission[]> {
return this.repository.getAllPermissions();
}
private async issueTokens(user: User): Promise<{ tokens: TokenPair }> {
const roles = await this.repository.getUserRoles(user.id);
const roleNames = roles.map((r) => r.name);
const dataScope = user.dataScope;
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, roles: roleNames, type: 'access' },
{
sub: user.id,
email: user.email,
roles: roleNames,
dataScope,
type: "access",
},
env.JWT_SECRET,
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: '15m' }
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "15m" },
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
{ sub: user.id, type: "refresh" },
env.JWT_SECRET,
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: '7d' }
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "7d" },
);
// 存储 refresh token hash
@@ -138,6 +203,7 @@ export class IamService {
name: user.name,
roles: roles.map((r) => r.name),
permissions: permissions.map((p) => p.name),
dataScope: user.dataScope,
};
}
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, Req } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
// RBAC 管理端点:角色/权限/视口查询
@Controller("iam")
export class RbacController {
constructor(private readonly service: IamService) {}
// 获取当前用户的视口配置L1 导航)
@Get("viewports")
async viewports(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const data = await this.service.getUserViewports(userId);
return { success: true as const, data };
}
// 获取当前用户的有效权限
@Get("permissions/effective")
async effectivePermissions(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const permissions = await this.service.getEffectivePermissions(userId);
return { success: true as const, data: { permissions } };
}
// 列出所有角色(管理端用)
@Get("roles")
async roles() {
const data = await this.service.getAllRoles();
return { success: true as const, data };
}
// 列出所有权限点(管理端用)
@Get("permissions")
async permissions() {
const data = await this.service.getAllPermissions();
return { success: true as const, data };
}
}

Some files were not shown because too many files have changed in this diff Show More