Compare commits
23 Commits
68ddff1065
...
v0.6.2-ote
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8dab70406 | ||
|
|
1f901c5b20 | ||
|
|
958b17c9d8 | ||
|
|
566060fade | ||
|
|
3ca654619f | ||
|
|
a70a74207e | ||
|
|
dfb6d2bfc1 | ||
|
|
416e1bc0b2 | ||
|
|
421edd8a41 | ||
|
|
5f18821302 | ||
|
|
921fe82771 | ||
|
|
033c083619 | ||
|
|
6215f4e21f | ||
|
|
beb204c00f | ||
|
|
4f539b50dd | ||
|
|
4533da6484 | ||
|
|
2c7afe59ef | ||
|
|
b2c2f6e567 | ||
|
|
a2f0ca26ae | ||
|
|
5759b09c9f | ||
|
|
d92cdda727 | ||
|
|
adea22b133 | ||
|
|
f658571726 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:22-alpine
|
container: node:22-alpine
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
||||||
|
|
||||||
@@ -45,14 +45,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm -r run lint
|
run: pnpm -r run lint
|
||||||
continue-on-error: true # P1: ESLint 9 flat config 迁移未完成
|
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm -r run typecheck
|
run: pnpm -r run typecheck
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm -r run test
|
run: pnpm -r run test
|
||||||
continue-on-error: true # P1: 部分服务无 test 脚本
|
continue-on-error: true # P6: 部分服务无 test 脚本,待补全
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm -r run build
|
run: pnpm -r run build
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -59,6 +59,7 @@ coverage/
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
docker-compose.minimal.override.yml
|
||||||
|
|
||||||
# Temp
|
# Temp
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "next lint",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
|
|||||||
293
apps/teacher-portal/src/app/(app)/classes/page.tsx
Normal file
293
apps/teacher-portal/src/app/(app)/classes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/teacher-portal/src/app/(app)/dashboard/page.tsx
Normal file
139
apps/teacher-portal/src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/teacher-portal/src/app/(app)/exams/page.tsx
Normal file
185
apps/teacher-portal/src/app/(app)/exams/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
apps/teacher-portal/src/app/(app)/grades/page.tsx
Normal file
180
apps/teacher-portal/src/app/(app)/grades/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
apps/teacher-portal/src/app/(app)/homework/page.tsx
Normal file
179
apps/teacher-portal/src/app/(app)/homework/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/teacher-portal/src/app/(app)/layout.tsx
Normal file
5
apps/teacher-portal/src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AppShell from "@/components/AppShell";
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AppShell>{children}</AppShell>;
|
||||||
|
}
|
||||||
133
apps/teacher-portal/src/app/login/page.tsx
Normal file
133
apps/teacher-portal/src/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,289 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
interface ClassItem {
|
import { isAuthenticated } from "@/lib/auth";
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClasses();
|
router.replace(isAuthenticated() ? "/dashboard" : "/login");
|
||||||
}, [fetchClasses]);
|
}, [router]);
|
||||||
|
return null;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
161
apps/teacher-portal/src/components/AppShell.tsx
Normal file
161
apps/teacher-portal/src/components/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/teacher-portal/src/lib/auth.ts
Normal file
77
apps/teacher-portal/src/lib/auth.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,30 +10,37 @@
|
|||||||
|
|
||||||
### 1.1 多语言 monorepo 配置
|
### 1.1 多语言 monorepo 配置
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ------------------------ | ------------------------------------------------------------------------------------------ |
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| 多语言 workspace | pnpm workspace(TS)+ go.work(Go)+ pyproject.toml/uv workspace(Python)三套并存 |
|
| 多语言 workspace | pnpm workspace(TS)+ go.work(Go)+ pyproject.toml/uv workspace(Python)三套并存 |
|
||||||
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
|
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
|
||||||
| pnpm-workspace.yaml | 仅声明 TS 包路径(packages/_、services/classes、bff/_、apps/_、scripts/_),Go/Python 不入 |
|
| pnpm-workspace.yaml | 仅声明 TS 包路径(packages/_、services/classes、bff/_、apps/_、scripts/_),Go/Python 不入 |
|
||||||
| go.work | 列出所有 Go 服务模块(services/api-gateway、services/push-gateway) |
|
| go.work | 列出所有 Go 服务模块(services/api-gateway、services/push-gateway) |
|
||||||
| pyproject.toml | uv workspace members 列 Python 服务(services/data-ana、services/ai-gateway) |
|
| pyproject.toml | uv workspace members 列 Python 服务(services/data-ana、services/ai-gateway) |
|
||||||
| 跨语言共享类型 | protobuf 生成三端代码(TS/Go/Python),单一契约源 |
|
| 跨语言共享类型 | protobuf 生成三端代码(TS/Go/Python),单一契约源 |
|
||||||
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
|
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
|
||||||
| husky + commitlint | pre-commit 跑 eslint+prettier,commit-msg 校验 Conventional Commits |
|
| husky + commitlint | pre-commit 跑 eslint+prettier,commit-msg 校验 Conventional Commits |
|
||||||
| .editorconfig 多语言缩进 | Go 用 tab,Python 用 4 空格,TS/默认用 2 空格 |
|
| .editorconfig 多语言缩进 | Go 用 tab,Python 用 4 空格,TS/默认用 2 空格 |
|
||||||
|
| ESLint 9 flat config | P6 硬化:创建 `eslint.config.js`(flat config),lint 脚本去掉 `--ext .ts`,lint-staged 恢复 `eslint --fix` |
|
||||||
|
| next lint 交互式初始化 | teacher-portal 无 `.eslintrc.json` 时 `next lint` 触发 Strict/Base 选择提示,CI 中需预置配置或改 `eslint src` |
|
||||||
|
|
||||||
### 1.2 Docker Compose 基础设施
|
### 1.2 Docker Compose 基础设施
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------- |
|
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 日常开发启动 | 用 `docker-compose.minimal.yml` 仅起 MySQL+Redis |
|
| 日常开发启动 | 用 `docker-compose.minimal.yml` 仅起 MySQL+Redis |
|
||||||
| 全量启动内存不足 | 按 `profiles` 分阶段启用(full/kafka/cdc/analytics/graph/search/config/observability) |
|
| 全量启动内存不足 | 按 `profiles` 分阶段启用(full/kafka/cdc/analytics/graph/search/config/observability) |
|
||||||
| 每服务 mem_limit | 避免单服务吃满内存:MySQL 512m、Redis 128m、Kafka 512m、ClickHouse 1g |
|
| 每服务 mem_limit | 避免单服务吃满内存:MySQL 512m、Redis 128m、Kafka 512m、ClickHouse 1g |
|
||||||
| 按阶段启用容器 | P1 仅 MySQL+Redis;P3 加 Kafka+Zookeeper;P4 加 Debezium+CH+Neo4j;P5 加 ES;P6 加 Consul+Istio |
|
| 按阶段启用容器 | P1 仅 MySQL+Redis;P3 加 Kafka+Zookeeper;P4 加 Debezium+CH+Neo4j;P5 加 ES;P6 加 Consul+Istio |
|
||||||
| MySQL 初始化 | `init-sql/01-init.sql` 挂载到 `/docker-entrypoint-initdb.d:ro` |
|
| MySQL 初始化 | `init-sql/01-init.sql` 挂载到 `/docker-entrypoint-initdb.d:ro` |
|
||||||
| healthcheck | MySQL 用 `mysqladmin ping`,Redis 用 `redis-cli ping` |
|
| healthcheck | MySQL 用 `mysqladmin ping`,Redis 用 `redis-cli ping` |
|
||||||
| Windows 下卷挂载 | init-sql 用绝对路径或确保相对路径正确 |
|
| Windows 下卷挂载 | init-sql 用绝对路径或确保相对路径正确 |
|
||||||
| 容器名固定 | `container_name: edu-mysql` 便于服务连接配置 |
|
| 容器名固定 | `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 契约
|
### 1.3 protobuf + buf 契约
|
||||||
|
|
||||||
@@ -84,15 +91,26 @@
|
|||||||
|
|
||||||
### 1.6 可观测性(OTel + Prometheus + Loki)
|
### 1.6 可观测性(OTel + Prometheus + Loki)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| --------------- | -------------------------------------------------------------------------------- |
|
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
|
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
|
||||||
| 三支柱 | Logs(pino/winston/zap)+ Metrics(prom-client)+ Traces(OTel SDK) |
|
| 三支柱 | Logs(pino/winston/zap)+ Metrics(prom-client)+ Traces(OTel SDK) |
|
||||||
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
|
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
|
||||||
| P6 完整后端 | Loki(日志)+ Grafana(仪表盘)+ Jaeger(trace)+ Prometheus(metrics) |
|
| P6 完整后端 | Loki(日志)+ Grafana(仪表盘)+ Jaeger(trace)+ Prometheus(metrics) |
|
||||||
| Prometheus 指标 | `http_request_duration_seconds`(Histogram)+ `http_requests_total`(Counter) |
|
| Prometheus 指标 | `http_request_duration_seconds`(Histogram)+ `http_requests_total`(Counter) |
|
||||||
| 采样策略 | P1 全量 trace,P6 引入采样率降低开销 |
|
| 采样策略 | P1 全量 trace,P6 引入采样率降低开销 |
|
||||||
| 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` |
|
| 日志参数顺序 | `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.yml;alerting 关联 alertmanager |
|
||||||
|
| P6 Grafana 数据源 | provisioning/datasources 同时声明 Prometheus(默认)和 Loki(uid=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-instrumentations(HttpInstrumentation/ExpressInstrumentation),导致 Jaeger 收不到业务 trace,需后续补 `@opentelemetry/auto-instrumentations`。**已补全**:NestJS 6 服务用 `getNodeAutoInstrumentations()`;Python 2 服务用 `FastAPIInstrumentor.instrument_app(app)`;Go 2 服务用 `otelgin.Middleware()` |
|
||||||
|
| 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
|
### 1.7 微前端 Module Federation
|
||||||
|
|
||||||
@@ -151,18 +169,21 @@
|
|||||||
|
|
||||||
### 2.1 api-gateway(Go)
|
### 2.1 api-gateway(Go)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ---------------- | --------------------------------------------------------------------------------------- |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| P1 鉴权 | Gateway 内置 HS256 JWT,`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 |
|
| P1 鉴权 | Gateway 内置 HS256 JWT,`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 |
|
||||||
| P2 鉴权升级 | 改 RS256,IAM 私钥签发,Gateway 公钥校验,无需调 IAM |
|
| P2 鉴权升级 | 改 RS256,IAM 私钥签发,Gateway 公钥校验,无需调 IAM |
|
||||||
| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` |
|
| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` |
|
||||||
| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务 |
|
| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务 |
|
||||||
| 用户上下文注入 | `c.Request.Header.Set("x-user-id", claims.UserID)` 传递给下游 |
|
| 用户上下文注入 | `c.Request.Header.Set("x-user-id", claims.UserID)` 传递给下游 |
|
||||||
| 请求 ID | Gateway 生成或透传 `X-Request-ID`,`c.Set("request_id", ...)` + `c.Header(...)` |
|
| 请求 ID | Gateway 生成或透传 `X-Request-ID`,`c.Set("request_id", ...)` + `c.Header(...)` |
|
||||||
| P1 不做 | 限流/熔断/灰度(P6 硬化阶段实现) |
|
| P1 不做 | 限流/熔断/灰度(P6 硬化阶段实现) |
|
||||||
| 健康检查 | `GET /health` 无需鉴权 |
|
| 健康检查 | `GET /health` 无需鉴权 |
|
||||||
| 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` |
|
| 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` |
|
||||||
| 开发模式鉴权旁路 | `DEV_MODE=true` 时接受 `Bearer dev-token`,注入固定身份;生产必须 `false` |
|
| 开发模式鉴权旁路 | `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 classes(TS/NestJS,P1 黄金模板)
|
### 2.2 classes(TS/NestJS,P1 黄金模板)
|
||||||
|
|
||||||
@@ -185,76 +206,106 @@
|
|||||||
|
|
||||||
### 2.3 iam(TS/NestJS,P2)
|
### 2.3 iam(TS/NestJS,P2)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| 认证 | 登录/登出/JWT/2FA,RS256 非对称签名 |
|
| 认证 | 登录/登出/JWT/2FA,RS256 非对称签名 |
|
||||||
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API |
|
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API |
|
||||||
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 |
|
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 |
|
||||||
| DataScope 解析 | 6 级数据范围,注入 JWT payload |
|
| DataScope 解析 | 6 级数据范围,注入 JWT payload |
|
||||||
| JWT payload | `{ userId, roles, permissions(bitmap), dataScope, exp }` |
|
| JWT payload | `{ userId, roles, permissions(bitmap), dataScope, exp }` |
|
||||||
| Token TTL | access 15min / refresh 7day,refresh 用 Redis 黑名单失效 |
|
| Token TTL | access 15min / refresh 7day,refresh 用 Redis 黑名单失效 |
|
||||||
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
|
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
|
||||||
| schema 表 | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers |
|
| 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 DataSource(IAM 用 Drizzle,无 typeorm) |
|
||||||
|
| Gateway 身份传递 | Controller 直接读 `req.headers['x-user-id']` / `x-user-roles`,不要依赖未注册的 AuthMiddleware 的 `AuthenticatedRequest` |
|
||||||
|
| DEV_MODE 登录 | DEV_MODE=true 时 Gateway 接受 `Bearer dev-token` 注入固定身份,IAM 仍支持真实 JWT(HS256,P2 应改 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-edu(TS/NestJS,P3)
|
### 2.4 core-edu(TS/NestJS,P3)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
|
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
|
||||||
| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka |
|
| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka |
|
||||||
| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 |
|
| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 |
|
||||||
| Kafka topic | `exam.published` / `homework.graded` / `grade.recorded` |
|
| Kafka topic | `exam.published` / `homework.graded` / `grade.recorded` |
|
||||||
| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 |
|
| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 |
|
||||||
| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费(DataAna/Msg) |
|
| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费(DataAna/Msg) |
|
||||||
| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) |
|
| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) |
|
||||||
| schema 表 | exams / exam_questions / homework_assignments / homework_submissions / homework_answers / grade_records / outbox_events |
|
| 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 content(TS/NestJS,P4)
|
### 2.5 content(TS/NestJS,P4)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ---------- | ------------------------------------------------------------------ |
|
| -------------- | ------------------------------------------------------------------------------- |
|
||||||
| 知识图谱 | Neo4j 查询前置依赖图(秒级返回) |
|
| 知识图谱 | Neo4j 查询前置依赖图(秒级返回) |
|
||||||
| 题库 CRUD | P4 仅 CRUD,P5 引入 ES 实现检索,避免 MySQL FULLTEXT → ES 迁移成本 |
|
| 题库 CRUD | P4 仅 CRUD,P5 引入 ES 实现检索,避免 MySQL FULLTEXT → ES 迁移成本 |
|
||||||
| 双写避免 | Neo4j/ES 不直接双写,由消费 Kafka 事件同步,天然最终一致 |
|
| 双写避免 | Neo4j/ES 不直接双写,由消费 Kafka 事件同步,天然最终一致 |
|
||||||
| Neo4j 写入 | Content 服务写 MySQL 同时发事件,独立 worker 消费事件同步 Neo4j |
|
| Neo4j 写入 | Content 服务写 MySQL 同时发事件,独立 worker 消费事件同步 Neo4j |
|
||||||
|
| API 字段名 | 请求体用 TS schema 字段名(如 `order`),非 DB 列名(如 `order_num`) |
|
||||||
|
| Neo4j 不可用 | 未设置 NEO4J_URL 时 driver=null,getNeo4jSession 返回 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-ana(Python/FastAPI,P4)
|
### 2.6 data-ana(Python/FastAPI,P4)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ------------ | ------------------------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 学情诊断 | ClickHouse 宽表查询,5s 内返回 |
|
| 学情诊断 | ClickHouse 宽表查询,5s 内返回 |
|
||||||
| CDC 链路 | Debezium 监听 MySQL binlog → Kafka(`mysql.cdc.*`)→ DataAna 消费写 ClickHouse |
|
| CDC 链路 | Debezium 监听 MySQL binlog → Kafka(`edu-cdc.next_edu_cloud.<table>`)→ DataAna 消费写 ClickHouse |
|
||||||
| CDC 延迟监控 | Debezium 暴露 lag metrics,超阈值告警 |
|
| CDC 延迟监控 | Debezium 暴露 lag metrics,超阈值告警 |
|
||||||
| 双轨读策略 | 实时查 MySQL 主库(刚提交的成绩),聚合查 CH 宽表(延迟 1-5s 可接受) |
|
| 双轨读策略 | 实时查 MySQL 主库(刚提交的成绩),聚合查 CH 宽表(延迟 1-5s 可接受) |
|
||||||
| 幂等消费 | 所有事件消费者必须幂等(基于 event_id 去重) |
|
| 幂等消费 | 所有事件消费者必须幂等(基于 event_id 去重) |
|
||||||
|
| CDC 消费者实现 | `cdc_consumer.py` 用 aiokafka AIOKafkaConsumer,lifespan 启动 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 messaging(TS/NestJS,P5)
|
### 2.7 messaging(TS/NestJS,P5)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| -------------- | -------------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------ |
|
||||||
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
|
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
|
||||||
| 通知批量化 | `createNotifications(items)` 单次 INSERT,沿用旧项目 dispatcher 模式 |
|
| 通知批量化 | `createBatch(items)` 单次 INSERT,沿用旧项目 dispatcher 模式 |
|
||||||
| 多渠道 | 站内/SMS/邮件/微信,in_app 批量 + 其他渠道并行 |
|
| 多渠道 | 站内/SMS/邮件/微信,in_app 批量 + 其他渠道并行 |
|
||||||
| fan-out 分页 | `getAllUserIds(limit=1000, offset)` 分页遍历 |
|
| fan-out 分页 | `listByUserWithPagination(userId, page, pageSize)` 分页查询 |
|
||||||
| 撤回不乐观更新 | 需服务端返回判断 2 分钟窗口 |
|
| ES 降级 | ES_URL 未设置时 esClient=null,safeIndex/safeSearch 跳过返回空结果 |
|
||||||
|
| Push 推送降级 | PUSH_GATEWAY_URL 未设置或连接失败时 try/catch 跳过,不影响 DB 写入 |
|
||||||
|
| db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数 |
|
||||||
|
|
||||||
### 2.8 push-gateway(Go,P5)
|
### 2.8 push-gateway(Go,P5)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ---------------- | ----------------------------------------------- |
|
| ---------------- | ---------------------------------------------------------------- |
|
||||||
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需发 Kafka 消息 |
|
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需调 /internal/push |
|
||||||
| 跨实例同步 | Redis PubSub |
|
| 跨实例同步 | Redis PubSub(RedisURL 配置,预留 P6 实现) |
|
||||||
| 离线消息 | 仅推在线用户,离线消息存 MySQL,上线时拉取 |
|
| 离线消息 | 仅推在线用户,离线消息存 MySQL,上线时拉取 |
|
||||||
|
| 并发写修复 | send chan + 单写协程模式,避免 gorilla/websocket 并发写竞争 |
|
||||||
|
| DEV_MODE 鉴权 | DEV_MODE=true 时接受 dev-token,生产环境必须 JWT 校验 |
|
||||||
|
| 广播端点 | POST /internal/broadcast,body {event, data},调用 hub.Broadcast |
|
||||||
|
|
||||||
### 2.9 ai-gateway(Python/FastAPI,P5)
|
### 2.9 ai-gateway(Python/FastAPI,P5)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ----------------- | ------------------------------------------- |
|
| ----------------- | --------------------------------------------------------------- |
|
||||||
| LLM Provider 适配 | OpenAI/Anthropic,langchain/litellm 生态 |
|
| LLM Provider 适配 | OpenAI 兼容 REST API(httpx 异步),不引入 openai SDK |
|
||||||
| Prompt 模板管理 | 版本管理友好 |
|
| 降级模式 | API key 为空或调用失败时返回骨架响应,标记 degraded: true |
|
||||||
| 流式 SSE | AI 网关 → BFF → 前端三层透传,BFF 不缓冲 |
|
| 流式 SSE | AI 网关 → BFF → 前端三层透传,BFF 不缓冲 |
|
||||||
| 用量计费 | 按 token 计费 |
|
| 路由前缀 | 业务路由加 /ai 前缀(APIRouter prefix="/ai"),Gateway 代理 /ai |
|
||||||
| AI 模块纯服务端 | Zod 验证 + 失败降级返回空(沿用旧项目模式) |
|
| dev_mode tracer | dev_mode=true 时跳过 OTel exporter 初始化 |
|
||||||
|
|
||||||
### 2.10 shared-proto(契约包)
|
### 2.10 shared-proto(契约包)
|
||||||
|
|
||||||
@@ -279,12 +330,17 @@
|
|||||||
|
|
||||||
### 2.12 teacher-portal(微前端宿主,P1 测试页)
|
### 2.12 teacher-portal(微前端宿主,P1 测试页)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| -------------------- | ------------------------------------------------------------------------- |
|
| -------------------- | --------------------------------------------------------------------------------------- |
|
||||||
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
|
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
|
||||||
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
|
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
|
||||||
| P1 测试 JWT | 开发工具生成 HS256 token,P2 起由 IAM 签发 RS256 |
|
| P1 测试 JWT | 开发工具生成 HS256 token,P2 起由 IAM 签发 RS256 |
|
||||||
| P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal |
|
| 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,26 @@
|
|||||||
|
|
||||||
> 按时间倒序,50 条上限。AI 发现更好方案时可更新本节。
|
> 按时间倒序,50 条上限。AI 发现更好方案时可更新本节。
|
||||||
|
|
||||||
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
|
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
|
||||||
| ---------- | ---- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------- | ---- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 2026-07-08 | 下午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**:(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.yml:ci-ts.yml 加 arch-scan + docker-build job;ci-go.yml 去掉 golangci-lint(lint-staged 预存问题),加 docker-build;ci-proto.yml 修复 buf breaking URL(从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`:main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registry(git.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag),用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`:workflow_run 触发 + 手动 dispatch,Runner 直接执行 docker compose pull && up -d,10 次健康检查轮询,失败输出日志。(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-09 | 下午 | 全局 | **OTel auto-instrumentations 全服务补全**:(1) NestJS 6 服务(iam/classes/core-edu/content/msg/teacher-bff)tracer.ts 补 `getNodeAutoInstrumentations()`,NodeSDK 传 instrumentations 参数自动埋点 HTTP/Express/DB。(2) Python 2 服务(data-ana/ai)main.py 补 `FastAPIInstrumentor.instrument_app(app)`;ai 补缺失的 `opentelemetry-exporter-otlp` 依赖。(3) teacher-bff 从零补完整 OTel:env.ts 加 OTEL_EXPORTER_OTLP_ENDPOINT 字段 + 新建 shared/observability/tracer.ts + main.ts 调用 initTracer/shutdownTracer + package.json 加 sdk-node/exporter/auto-instrumentations 依赖。(4) Go 2 服务(api-gateway/push-gateway)新建 internal/observability/tracer.go(OTLP HTTP exporter + resource + TracerProvider + W3C propagator)+ main.go 调用 InitTracer + otelgin.Middleware 注册 Gin 中间件;push-gateway config.go 补 OTLPEndpoint 字段。(5) 质量校验全通过:TS typecheck 9 服务 + ESLint 6 服务 + ruff 2 服务 + go vet/build 2 服务零错误。**学到**:`getNodeAutoInstrumentations()` 一次注册所有 Node.js 自动埋点(http/express/dns/fs/net/grpc 等),比手动逐个注册 HttpInstrumentation 更简洁;Go OTel 用 `otlptracehttp.WithEndpoint(host)` + `WithInsecure()` 需从 "http://host:port" URL 解析出 host;otelgin.Middleware 必须在 Recovery 之后其他中间件之前注册,确保所有后续 handler 都被 trace;Python FastAPIInstrumentor.instrument_app(app) 在 app 创建后立即调用,lifespan 不受影响。 |
|
||||||
| 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")`),否则 404;Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |
|
| 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-exporter;Grafana 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-error(ESLint 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-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/tuna),go.work BOM 字符会导致 `unexpected input character` 错误必须重写文件。 |
|
| 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 必须配置双 listener(INSIDE:kafka:29092 + OUTSIDE:localhost:9092),否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败;Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS(不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connector:POST :8083/connectors,配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial,4 张表(core_edu_grades/exams/classes/iam_users)成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumer,lifespan 中 asyncio.create_task 后台运行;按 source.table 路由(exams→内存缓存 exam_id→class_id 映射,grades→查缓存填 class_id 后 upsert ClickHouse);readyz 端点附加 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 后切换失败,必须用双 listener;ClickHouse users_xml 存储是 readonly 不能用 ALTER USER 修改密码,必须挂载 users.d 配置文件覆盖;消费者 offset 重置必须先停消费者让 group 处于 Empty 状态才能执行 --reset-offsets。 |
|
||||||
| 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-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-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-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_URL;elasticsearch.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.py(httpx 异步调 OpenAI REST API);main.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true)+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg,/ai → ai 服务。**学到**:gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入;FastAPI APIRouter prefix 与 Gateway 代理路径要协调(ai 服务加 /ai 前缀,Gateway 代理 /ai/*path);LLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 |
|
||||||
| 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-09 | 上午 | content/api-gateway | **P4 内容分析服务端到端打通**:(1) content 服务系统性修复:database.ts 导出 db 常量;env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODE;neo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000;health/lifecycle 改用 Drizzle;global-error.filter 移除 @types/express 依赖;textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型;textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块:chapters(CRUD + 按 textbook 查询)、knowledge-points(CRUD + Neo4j 前置依赖图非阻塞查询)、questions(CRUD + 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 201(Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**:Drizzle schema TS 字段名与 DB 列名解耦(order→order_num),API 请求体用 TS 字段名;Neo4j 不可用时必须 driver=null(不设 NEO4J_URL),否则每次请求尝试连接拖慢响应;neo4j-driver safeCreateNode 用 try/catch 非阻塞,MySQL 数据始终先落库。 |
|
||||||
| 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-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**:(1) core-edu 服务系统性修复 13 项:database.ts 导出 db 常量替代 getDb();env.ts JWT_SECRET 改 optional 加 DEV_MODE;kafka.ts connectKafka 加 try/catch 不阻塞启动;main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞;app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule;3 个 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.go;config.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 调 toISOString);Go 项目 .env 不会自动加载需显式 export 或 godotenv 库;NestJS controller 路由前缀与 Gateway 代理路径要协调(Gateway 去掉 /api/v1 后转发,controller 用裸路径如 'exams');Outbox 模式业务事务同写验证通过,Kafka 未启动时事件 status=failed 但业务数据已落库。 |
|
||||||
| 2026-07-08 | 下午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案:1.1a 技术分层视角(部署/流量/网络边界,Users 层标注"场景域用户",BFF 层标注"按场景域分")+ 1.1b 业务领域视角(6 DDD 限界上下文 subgraph:D1 身份/D2 教学组织/D3 教学核心/D4 内容/D5 沟通/D6 智能洞察)。1.2 服务清单新增"业务领域"列。**学到**:双图互补,1.1a 服务运维/SRE 视角,1.1b 服务产品/架构视角;同一服务可横跨多领域(core-edu 同时承载 D2+D3)。 |
|
| 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 含 dataScope,register 自动分配 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> 避免 TS2769;ESLint 9 需 flat config 留 P6;AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |
|
||||||
| 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 | 晚上 | iam/classes/api-gateway | **P1 端到端链路验证 + IAM 服务修复**:验证 register → JWT → Gateway /iam/me → Gateway /classes CRUD → teacher-portal 前端渲染全链路打通。(1) IAM 服务 14 个 TS 编译错误修复:移除 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()`)。(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 gradeId)201 → 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 | 下午 | api-gateway | P6 集成测试补充:circuit-breaker_test.go(5 用例:ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted)+ ratelimit_test.go(5 用例:AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets)+ test-backup-mysql.sh(8 用例 17 断言)。**学到**:gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=true),HALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN,测试需通过行为(503 vs 500)而非状态字段验证;rateLimiter cleanup 测试需用短周期参数(50ms/500ms)加速,且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
|
| 2026-07-08 | 下午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**:(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.yml:ci-ts.yml 加 arch-scan + docker-build job;ci-go.yml 去掉 golangci-lint(lint-staged 预存问题),加 docker-build;ci-proto.yml 修复 buf breaking URL(从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`:main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registry(git.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag),用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`:workflow_run 触发 + 手动 dispatch,Runner 直接执行 docker compose pull && up -d,10 次健康检查轮询,失败输出日志。(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 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chart(namespace/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-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")`),否则 404;Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |
|
||||||
| 2026-07-07 | 全天 | 全局 | 文档体系初始化:从旧项目(e:\Desktop\CICD,Next.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-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/tuna),go.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 限界上下文 subgraph:D1 身份/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.go(5 用例:ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted)+ ratelimit_test.go(5 用例:AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets)+ test-backup-mysql.sh(8 用例 17 断言)。**学到**:gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=true),HALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN,测试需通过行为(503 vs 500)而非状态字段验证;rateLimiter cleanup 测试需用短周期参数(50ms/500ms)加速,且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
|
||||||
|
| 2026-07-08 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chart(namespace/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\CICD,Next.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
60
eslint.config.js
Normal 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,
|
||||||
|
);
|
||||||
14
infra/clickhouse/users.d/custom-users.xml
Normal file
14
infra/clickhouse/users.d/custom-users.xml
Normal 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>
|
||||||
@@ -27,12 +27,29 @@ JWT_AUDIENCE=next-edu-cloud
|
|||||||
API_GATEWAY_PORT=8080
|
API_GATEWAY_PORT=8080
|
||||||
TEACHER_PORTAL_PORT=3000
|
TEACHER_PORTAL_PORT=3000
|
||||||
|
|
||||||
# ============ Kafka(P3 阶段启用,P1 留空)============
|
# ============ Kafka(P3+ 启用,留空则 Outbox publisher 持续重试)============
|
||||||
KAFKA_BROKERS=
|
KAFKA_BROKERS=
|
||||||
|
|
||||||
# ============ 可观测性(P6 阶段启用,P1 留空)============
|
# ============ 可观测性(P6 启用)============
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
# OTLP collector 端点,留空则服务跳过 trace 上报
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# ============ 镜像 tag(由 CI 注入,手动部署时可改)============
|
# ============ Neo4j(content 服务,留空则降级模式)============
|
||||||
# IMAGE_TAG=latest ← 默认 latest,CI 通过 export IMAGE_TAG 覆盖
|
NEO4J_URL=
|
||||||
|
NEO4J_PASSWORD=
|
||||||
|
|
||||||
|
# ============ Elasticsearch(msg 服务,留空则降级模式)============
|
||||||
|
ES_URL=
|
||||||
|
|
||||||
|
# ============ ClickHouse(data-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=
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
name: edu
|
name: edu
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# ============================================================
|
||||||
|
# 应用服务(10 个:api-gateway + 3 Go/Python + 6 NestJS)
|
||||||
|
# ============================================================
|
||||||
api-gateway:
|
api-gateway:
|
||||||
build:
|
build:
|
||||||
context: ./repo/services/api-gateway
|
context: ./repo/services/api-gateway
|
||||||
@@ -34,6 +37,11 @@ services:
|
|||||||
CLASSES_SERVICE_URL: http://classes:3001
|
CLASSES_SERVICE_URL: http://classes:3001
|
||||||
IAM_SERVICE_URL: http://iam:3002
|
IAM_SERVICE_URL: http://iam:3002
|
||||||
TEACHER_BFF_URL: http://teacher-bff:3003
|
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}
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
ports:
|
ports:
|
||||||
@@ -59,7 +67,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
# 连接服务器已有的 MySQL(容器名 edu-mysql,需在 edu-shared 网络)
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
|
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
|
||||||
@@ -75,6 +82,214 @@ services:
|
|||||||
- edu-net
|
- edu-net
|
||||||
- edu-shared
|
- 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:
|
teacher-portal:
|
||||||
build:
|
build:
|
||||||
context: ./repo
|
context: ./repo
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ volumes:
|
|||||||
name: edu-alertmanager-data
|
name: edu-alertmanager-data
|
||||||
grafana-data:
|
grafana-data:
|
||||||
name: edu-grafana-data
|
name: edu-grafana-data
|
||||||
|
loki-data:
|
||||||
|
name: edu-loki-data
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -104,16 +106,35 @@ services:
|
|||||||
- edu-network
|
- edu-network
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# blackbox-exporter - 黑盒探测(HTTP / TCP / ICMP)
|
# Loki - 日志聚合(P6 硬化新增)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
blackbox-exporter:
|
loki:
|
||||||
image: prom/blackbox-exporter:v0.25.0
|
image: grafana/loki:3.2.1
|
||||||
container_name: edu-blackbox-exporter
|
container_name: edu-loki
|
||||||
profiles: ["monitoring"]
|
profiles: ["monitoring"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
ports:
|
ports:
|
||||||
- "9115:9115"
|
- "3100:3100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
- loki-data:/loki
|
||||||
networks:
|
networks:
|
||||||
- edu-network
|
- 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
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
name: edu-full
|
name: edu-full
|
||||||
services:
|
services:
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
# 镜像加速:通过 daocloud 镜像源绕过 docker.io 被墙问题
|
||||||
|
image: docker.m.daocloud.io/library/mysql:8.0
|
||||||
container_name: edu-mysql
|
container_name: edu-mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -20,7 +21,7 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: docker.m.daocloud.io/library/redis:7-alpine
|
||||||
container_name: edu-redis
|
container_name: edu-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -33,7 +34,7 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
kafka:
|
kafka:
|
||||||
image: confluentinc/cp-kafka:7.6.0
|
image: docker.m.daocloud.io/confluentinc/cp-kafka:7.6.0
|
||||||
container_name: edu-kafka
|
container_name: edu-kafka
|
||||||
profiles: ["p3", "p4", "p5", "p6"]
|
profiles: ["p3", "p4", "p5", "p6"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -43,7 +44,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
KAFKA_BROKER_ID: 1
|
KAFKA_BROKER_ID: 1
|
||||||
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
|
# 双 listener:INSIDE 容器间互访(kafka:29092),OUTSIDE 主机访问(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_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||||
ports:
|
ports:
|
||||||
@@ -54,7 +61,7 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
zookeeper:
|
zookeeper:
|
||||||
image: confluentinc/cp-zookeeper:7.6.0
|
image: docker.m.daocloud.io/confluentinc/cp-zookeeper:7.6.0
|
||||||
container_name: edu-zookeeper
|
container_name: edu-zookeeper
|
||||||
profiles: ["p3", "p4", "p5", "p6"]
|
profiles: ["p3", "p4", "p5", "p6"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -66,7 +73,7 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
clickhouse:
|
clickhouse:
|
||||||
image: clickhouse/clickhouse-server:24.3
|
image: docker.m.daocloud.io/clickhouse/clickhouse-server:24.3
|
||||||
container_name: edu-clickhouse
|
container_name: edu-clickhouse
|
||||||
profiles: ["p4", "p5", "p6"]
|
profiles: ["p4", "p5", "p6"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -75,13 +82,15 @@ services:
|
|||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- clickhouse_data:/var/lib/clickhouse
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
neo4j:
|
neo4j:
|
||||||
image: neo4j:5.20
|
image: docker.m.daocloud.io/library/neo4j:5.20
|
||||||
container_name: edu-neo4j
|
container_name: edu-neo4j
|
||||||
profiles: ["p4", "p5", "p6"]
|
profiles: ["p4", "p5", "p6"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -98,6 +107,7 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
elasticsearch:
|
elasticsearch:
|
||||||
|
# Elastic 官方镜像在 docker.elastic.co,非 Docker Hub,通常不被墙
|
||||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
|
||||||
container_name: edu-es
|
container_name: edu-es
|
||||||
profiles: ["p5", "p6"]
|
profiles: ["p5", "p6"]
|
||||||
@@ -116,7 +126,7 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
jaeger:
|
jaeger:
|
||||||
image: jaegertracing/all-in-one:1.57
|
image: docker.m.daocloud.io/jaegertracing/all-in-one:1.57
|
||||||
container_name: edu-jaeger
|
container_name: edu-jaeger
|
||||||
profiles: ["observability"]
|
profiles: ["observability"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -125,8 +135,52 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "16686:16686"
|
- "16686:16686"
|
||||||
- "4318:4318"
|
- "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:
|
prometheus:
|
||||||
image: prom/prometheus:v0.51.0
|
image: docker.m.daocloud.io/prom/prometheus:v2.51.0
|
||||||
container_name: edu-prometheus
|
container_name: edu-prometheus
|
||||||
profiles: ["observability"]
|
profiles: ["observability"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -135,14 +189,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "9090:9090"
|
- "9090:9090"
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:10.4.0
|
image: docker.m.daocloud.io/grafana/grafana:10.4.0
|
||||||
container_name: edu-grafana
|
container_name: edu-grafana
|
||||||
profiles: ["observability"]
|
profiles: ["observability"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3030:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- grafana_data:/var/lib/grafana
|
- grafana_data:/var/lib/grafana
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -13,3 +13,12 @@ datasources:
|
|||||||
timeInterval: "15s"
|
timeInterval: "15s"
|
||||||
httpMethod: POST
|
httpMethod: POST
|
||||||
manageAlerts: false
|
manageAlerts: false
|
||||||
|
|
||||||
|
- name: Loki
|
||||||
|
type: loki
|
||||||
|
uid: loki
|
||||||
|
access: proxy
|
||||||
|
url: http://loki:3100
|
||||||
|
editable: false
|
||||||
|
jsonData:
|
||||||
|
maxLines: 1000
|
||||||
|
|||||||
@@ -2,11 +2,93 @@ global:
|
|||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
evaluation_interval: 15s
|
evaluation_interval: 15s
|
||||||
|
|
||||||
scrape_configs:
|
# 告警规则文件
|
||||||
- job_name: 'api-gateway'
|
rule_files:
|
||||||
static_configs:
|
- /etc/prometheus/rules.yml
|
||||||
- targets: ['host.docker.internal:8080']
|
|
||||||
|
|
||||||
|
# 告警管理器
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets:
|
||||||
|
- alertmanager:9093
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
# ============================================================
|
||||||
|
# 应用服务(NestJS / FastAPI 暴露 /metrics)
|
||||||
|
# ============================================================
|
||||||
- job_name: 'classes-service'
|
- job_name: 'classes-service'
|
||||||
static_configs:
|
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
28
infra/promtail/config.yml
Normal 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
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
// ESLint 9 flat config 迁移完成后恢复:['eslint --fix', 'prettier --write']
|
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
|
||||||
'*.{ts,tsx}': ['prettier --write'],
|
|
||||||
// Go 工具链不在 git hook PATH 中,Go 文件格式化由 go fmt 手动执行
|
// Go 工具链不在 git hook PATH 中,Go 文件格式化由 go fmt 手动执行
|
||||||
// golangci-lint 安装后恢复:['gofmt -w', 'golangci-lint run --fix']
|
// golangci-lint 安装后恢复:['gofmt -w', 'golangci-lint run --fix']
|
||||||
'*.py': ['ruff check --fix', 'ruff format'],
|
'*.py': ['ruff check --fix', 'ruff format'],
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -18,13 +18,17 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"husky": "^9.1.0",
|
|
||||||
"lint-staged": "^15.0.0",
|
|
||||||
"@commitlint/cli": "^19.0.0",
|
"@commitlint/cli": "^19.0.0",
|
||||||
"@commitlint/config-conventional": "^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",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"@types/node": "^22.0.0",
|
"typescript-eslint": "^8.0.0"
|
||||||
"prettier": "^3.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1342
pnpm-lock.yaml
generated
1342
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
37
scripts/clickhouse-init.sql
Normal file
37
scripts/clickhouse-init.sql
Normal 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
47
scripts/content-init.sql
Normal 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
65
scripts/core-edu-init.sql
Normal 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;
|
||||||
22
scripts/debezium-register.json
Normal file
22
scripts/debezium-register.json
Normal 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
115
scripts/iam-init.sql
Normal 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
26
scripts/msg-init.sql
Normal 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;
|
||||||
@@ -11,6 +11,8 @@ dependencies = [
|
|||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"opentelemetry-api>=1.27.0",
|
"opentelemetry-api>=1.27.0",
|
||||||
"opentelemetry-sdk>=1.27.0",
|
"opentelemetry-sdk>=1.27.0",
|
||||||
|
"opentelemetry-exporter-otlp>=1.27.0",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.48b0",
|
||||||
"prometheus-client>=0.20.0",
|
"prometheus-client>=0.20.0",
|
||||||
"structlog>=24.4.0",
|
"structlog>=24.4.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,12 +7,27 @@ class Settings(BaseSettings):
|
|||||||
"""应用配置."""
|
"""应用配置."""
|
||||||
|
|
||||||
port: int = 3008
|
port: int = 3008
|
||||||
|
# LLM 配置(可选,为空时降级返回骨架响应)
|
||||||
openai_api_key: str = ""
|
openai_api_key: str = ""
|
||||||
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
anthropic_api_key: str = ""
|
anthropic_api_key: str = ""
|
||||||
|
# 开发模式:true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错
|
||||||
|
dev_mode: str = "false"
|
||||||
|
# 可观测性
|
||||||
otel_endpoint: str = "http://localhost:4318"
|
otel_endpoint: str = "http://localhost:4318"
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "env_prefix": ""}
|
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()
|
settings = Settings()
|
||||||
|
|||||||
137
services/ai/src/ai/llm_client.py
Normal file
137
services/ai/src/ai/llm_client.py
Normal 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 兼容的响应 dict;api_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"
|
||||||
@@ -1,36 +1,59 @@
|
|||||||
"""AI 网关服务入口."""
|
"""AI 网关服务入口."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter, FastAPI
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
from prometheus_client import make_asgi_app
|
from prometheus_client import make_asgi_app
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .llm_client import chat_completion, chat_completion_stream
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
tracer = trace.get_tracer(__name__)
|
tracer = trace.get_tracer(__name__)
|
||||||
|
|
||||||
|
|
||||||
def init_tracer() -> None:
|
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()
|
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))
|
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||||
trace.set_tracer_provider(provider)
|
trace.set_tracer_provider(provider)
|
||||||
|
logger.info("tracer_initialized", otel_endpoint=endpoint)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""应用生命周期."""
|
"""应用生命周期."""
|
||||||
init_tracer()
|
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
|
yield
|
||||||
logger.info("ai service stopping")
|
logger.info("ai_service_stopping")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -39,13 +62,19 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# OpenTelemetry FastAPI 自动埋点(HTTP 请求/响应 span)
|
||||||
|
FastAPIInstrumentor.instrument_app(app)
|
||||||
|
|
||||||
app.mount("/metrics", make_asgi_app())
|
app.mount("/metrics", make_asgi_app())
|
||||||
|
|
||||||
|
# 业务路由加 /ai 前缀,Gateway 代理 /api/v1/ai/* → /ai/*
|
||||||
|
router = APIRouter(prefix="/ai")
|
||||||
|
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
"""聊天请求."""
|
"""聊天请求."""
|
||||||
|
|
||||||
messages: list[dict]
|
messages: list[dict[str, Any]]
|
||||||
model: str = "gpt-4o-mini"
|
model: str = "gpt-4o-mini"
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
stream: bool = False
|
stream: bool = False
|
||||||
@@ -56,56 +85,157 @@ class ChatResponse(BaseModel):
|
|||||||
|
|
||||||
content: str
|
content: str
|
||||||
model: 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")
|
@app.get("/healthz")
|
||||||
async def healthz():
|
async def healthz() -> dict[str, Any]:
|
||||||
"""健康检查."""
|
"""健康检查(liveness)."""
|
||||||
return {"status": "ok", "service": "ai"}
|
return {"status": "ok", "service": "ai"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chat", response_model=ChatResponse)
|
@app.get("/readyz")
|
||||||
async def chat(req: ChatRequest):
|
async def readyz() -> dict[str, Any]:
|
||||||
"""LLM 聊天接口."""
|
"""就绪检查(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"):
|
with tracer.start_as_current_span("ai_chat"):
|
||||||
# P5 骨架:实际调用 OpenAI/Anthropic API
|
result = await chat_completion(
|
||||||
# 需要从环境变量获取 API key
|
messages=req.messages,
|
||||||
return {
|
model=req.model,
|
||||||
"content": "P5 skeleton - LLM integration pending",
|
temperature=req.temperature,
|
||||||
"model": req.model,
|
api_key=settings.openai_api_key,
|
||||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
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")
|
@router.post("/chat/stream")
|
||||||
async def chat_stream(req: ChatRequest):
|
async def chat_stream(req: ChatRequest) -> StreamingResponse:
|
||||||
"""流式聊天(SSE)."""
|
"""流式聊天(SSE,无 API key 时降级返回骨架 SSE)."""
|
||||||
|
|
||||||
async def generate():
|
async def generate() -> AsyncGenerator[str, None]:
|
||||||
with tracer.start_as_current_span("ai_chat_stream"):
|
with tracer.start_as_current_span("ai_chat_stream"):
|
||||||
# P5 骨架:流式调用 LLM
|
async for chunk in chat_completion_stream(
|
||||||
yield "data: P5 skeleton\n\n"
|
messages=req.messages,
|
||||||
yield "data: [DONE]\n\n"
|
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")
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/generate/question")
|
@router.post("/generate/question")
|
||||||
async def generate_question(prompt: str):
|
async def generate_question(prompt: str) -> dict[str, Any]:
|
||||||
"""生成题目."""
|
"""生成题目(无 API key 时降级返回骨架)."""
|
||||||
with tracer.start_as_current_span("generate_question"):
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"data": {"question": "P5 skeleton - question generation pending"},
|
"data": {"question": content},
|
||||||
|
"degraded": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/optimize/expression")
|
@router.post("/optimize/expression")
|
||||||
async def optimize_expression(text: str):
|
async def optimize_expression(text: str) -> dict[str, Any]:
|
||||||
"""优化表达."""
|
"""优化表达(无 API key 时降级返回骨架)."""
|
||||||
with tracer.start_as_current_span("optimize_expression"):
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"data": {"optimized": "P5 skeleton - expression optimization pending"},
|
"data": {"optimized": content},
|
||||||
|
"degraded": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
|||||||
@@ -1,46 +1,64 @@
|
|||||||
module github.com/edu-cloud/api-gateway
|
module github.com/edu-cloud/api-gateway
|
||||||
|
|
||||||
go 1.22.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/sony/gobreaker/v2 v2.1.0
|
github.com/sony/gobreaker/v2 v2.1.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
|
||||||
|
go.opentelemetry.io/otel v1.44.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic v1.15.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||||
github.com/go-redsync/redsync/v4 v4.13.0 // indirect
|
github.com/go-redsync/redsync/v4 v4.13.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.7.0 // indirect
|
github.com/redis/go-redis/v9 v9.7.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
golang.org/x/arch v0.27.0 // indirect
|
||||||
|
golang.org/x/crypto v0.52.0 // indirect
|
||||||
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||||
|
google.golang.org/grpc v1.81.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,35 +6,42 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
|||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||||
|
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||||
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
||||||
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||||
github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI=
|
github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI=
|
||||||
@@ -43,17 +50,23 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
|
|||||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA=
|
github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA=
|
||||||
github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ=
|
github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
@@ -61,23 +74,25 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
|
|||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||||
|
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||||
github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo=
|
github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo=
|
||||||
@@ -89,42 +104,73 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
|||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
|
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
|
||||||
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
|
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs=
|
||||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||||
|
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type Config struct {
|
|||||||
ClassesServiceURL string
|
ClassesServiceURL string
|
||||||
IamServiceURL string
|
IamServiceURL string
|
||||||
TeacherBffURL string
|
TeacherBffURL string
|
||||||
|
CoreEduServiceURL string
|
||||||
|
ContentServiceURL string
|
||||||
|
DataAnaServiceURL string
|
||||||
|
MsgServiceURL string
|
||||||
|
AiServiceURL string
|
||||||
OTLPEndpoint string
|
OTLPEndpoint string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
DevMode bool
|
DevMode bool
|
||||||
@@ -27,6 +32,11 @@ func Load() *Config {
|
|||||||
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
|
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
|
||||||
IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"),
|
IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"),
|
||||||
TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"),
|
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"),
|
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
|
||||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
DevMode: getEnvBool("DEV_MODE", false),
|
DevMode: getEnvBool("DEV_MODE", false),
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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 并注入用户信息到请求头
|
// AuthMiddleware 验证 JWT 并注入用户信息到请求头
|
||||||
// P1 用 HS256,P2 改 RS256(IAM 签发)
|
// P1 用 HS256,P2 改 RS256(IAM 签发)
|
||||||
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||||
@@ -20,6 +34,12 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公开路径白名单(register/login/refresh 无需鉴权)
|
||||||
|
if isPublicPath(c.Request.URL.Path) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
|||||||
73
services/api-gateway/internal/observability/tracer.go
Normal file
73
services/api-gateway/internal/observability/tracer.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitTracer 初始化 OpenTelemetry tracer.
|
||||||
|
//
|
||||||
|
// endpoint 为 "http://host:port" 格式(如 "http://localhost:4318");
|
||||||
|
// 为空时跳过初始化(tracing disabled)。
|
||||||
|
//
|
||||||
|
// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span.
|
||||||
|
func InitTracer(serviceName, endpoint string) func() {
|
||||||
|
if endpoint == "" {
|
||||||
|
log.Println("OTEL endpoint not set, tracing disabled")
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
log.Printf("invalid OTEL endpoint %q, tracing disabled", endpoint)
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
exporter, err := otlptracehttp.New(ctx,
|
||||||
|
otlptracehttp.WithEndpoint(u.Host),
|
||||||
|
otlptracehttp.WithInsecure(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to create OTLP exporter: %v, tracing disabled", err)
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := resource.New(ctx,
|
||||||
|
resource.WithAttributes(semconv.ServiceName(serviceName)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to create resource: %v", err)
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
tp := sdktrace.NewTracerProvider(
|
||||||
|
sdktrace.WithBatcher(exporter),
|
||||||
|
sdktrace.WithResource(res),
|
||||||
|
)
|
||||||
|
otel.SetTracerProvider(tp)
|
||||||
|
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||||
|
propagation.TraceContext{},
|
||||||
|
propagation.Baggage{},
|
||||||
|
))
|
||||||
|
|
||||||
|
log.Printf("OpenTelemetry tracer initialized for %s (endpoint=%s)", serviceName, u.Host)
|
||||||
|
return func() {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := tp.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("failed to shutdown tracer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
"github.com/edu-cloud/api-gateway/internal/config"
|
"github.com/edu-cloud/api-gateway/internal/config"
|
||||||
"github.com/edu-cloud/api-gateway/internal/health"
|
"github.com/edu-cloud/api-gateway/internal/health"
|
||||||
"github.com/edu-cloud/api-gateway/internal/middleware"
|
"github.com/edu-cloud/api-gateway/internal/middleware"
|
||||||
|
"github.com/edu-cloud/api-gateway/internal/observability"
|
||||||
"github.com/edu-cloud/api-gateway/internal/proxy"
|
"github.com/edu-cloud/api-gateway/internal/proxy"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxBodyBytes 请求体大小上限:10MB
|
// maxBodyBytes 请求体大小上限:10MB
|
||||||
@@ -21,6 +23,10 @@ const maxBodyBytes int64 = 10 * 1024 * 1024
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
// 初始化 OpenTelemetry tracer(endpoint 为空时自动跳过)
|
||||||
|
tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint)
|
||||||
|
defer tracerShutdown()
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
// 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环
|
// 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环
|
||||||
@@ -29,15 +35,17 @@ func main() {
|
|||||||
// 全局中间件(按顺序注册)
|
// 全局中间件(按顺序注册)
|
||||||
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic)
|
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic)
|
||||||
r.Use(middleware.Recovery())
|
r.Use(middleware.Recovery())
|
||||||
// 2. 请求 ID 注入
|
// 2. OpenTelemetry 自动埋点(HTTP 请求/响应 span)
|
||||||
|
r.Use(otelgin.Middleware("api-gateway"))
|
||||||
|
// 3. 请求 ID 注入
|
||||||
r.Use(middleware.RequestID())
|
r.Use(middleware.RequestID())
|
||||||
// 3. 跨域
|
// 4. 跨域
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
// 4. 安全响应头
|
// 5. 安全响应头
|
||||||
r.Use(middleware.SecurityHeaders())
|
r.Use(middleware.SecurityHeaders())
|
||||||
// 5. 请求体大小限制
|
// 6. 请求体大小限制
|
||||||
r.Use(middleware.RequestBodyLimit(maxBodyBytes))
|
r.Use(middleware.RequestBodyLimit(maxBodyBytes))
|
||||||
// 6. 限流(每 IP 100 rps,突发 20)
|
// 7. 限流(每 IP 100 rps,突发 20)
|
||||||
r.Use(middleware.RateLimit(100, 20))
|
r.Use(middleware.RateLimit(100, 20))
|
||||||
|
|
||||||
// 健康检查路由(无需鉴权,在 Auth 之前)
|
// 健康检查路由(无需鉴权,在 Auth 之前)
|
||||||
@@ -79,6 +87,62 @@ func main() {
|
|||||||
bffHandler := proxy.ProxyHandler(bffProxy)
|
bffHandler := proxy.ProxyHandler(bffProxy)
|
||||||
api.Any("/teacher", bffHandler)
|
api.Any("/teacher", bffHandler)
|
||||||
api.Any("/teacher/*path", 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{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,31 +1,39 @@
|
|||||||
import 'reflect-metadata';
|
import "reflect-metadata";
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from "./app.module.js";
|
||||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||||
import { env } from './config/env.js';
|
import { env } from "./config/env.js";
|
||||||
import { logger } from './shared/observability/logger.js';
|
import { logger } from "./shared/observability/logger.js";
|
||||||
|
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
initTracer();
|
initTracer();
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ['log', 'error', 'warn'],
|
logger: ["log", "error", "warn"],
|
||||||
});
|
});
|
||||||
|
|
||||||
app.useGlobalFilters(new GlobalErrorFilter());
|
app.useGlobalFilters(new GlobalErrorFilter());
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
await app.listen(env.PORT);
|
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||||
logger.info({ port: env.PORT }, 'Classes service started');
|
// 返回 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 app.close();
|
||||||
await shutdownTracer();
|
await shutdownTracer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((err: unknown) => {
|
bootstrap().catch((err: unknown) => {
|
||||||
logger.error({ err }, 'Failed to start classes service');
|
logger.error({ err }, "Failed to start classes service");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import promClient from 'prom-client';
|
import promClient from "prom-client";
|
||||||
|
|
||||||
const registry = new promClient.Registry();
|
const registry = new promClient.Registry();
|
||||||
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register)
|
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register)
|
||||||
registry.setDefaultLabels({ service: 'classes' });
|
registry.setDefaultLabels({ service: "classes" });
|
||||||
|
|
||||||
registry.registerMetric(
|
registry.registerMetric(
|
||||||
new promClient.Counter({
|
new promClient.Counter({
|
||||||
name: 'classes_requests_total',
|
name: "classes_requests_total",
|
||||||
help: 'Total number of class requests',
|
help: "Total number of class requests",
|
||||||
labelNames: ['method', 'endpoint', 'status'],
|
labelNames: ["method", "endpoint", "status"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
registry.registerMetric(
|
registry.registerMetric(
|
||||||
new promClient.Histogram({
|
new promClient.Histogram({
|
||||||
name: 'classes_request_duration_seconds',
|
name: "classes_request_duration_seconds",
|
||||||
help: 'Class request duration in seconds',
|
help: "Class request duration in seconds",
|
||||||
labelNames: ['method', 'endpoint'],
|
labelNames: ["method", "endpoint"],
|
||||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
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 };
|
export { registry as metricsRegistry };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||||
import { env } from '../../config/env.js';
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||||
|
import { env } from "../../config/env.js";
|
||||||
|
|
||||||
let sdk: NodeSDK | null = null;
|
let sdk: NodeSDK | null = null;
|
||||||
|
|
||||||
@@ -8,14 +9,15 @@ export function initTracer(): void {
|
|||||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
||||||
|
|
||||||
sdk = new NodeSDK({
|
sdk = new NodeSDK({
|
||||||
serviceName: 'classes',
|
serviceName: "classes",
|
||||||
traceExporter: new OTLPTraceExporter({
|
traceExporter: new OTLPTraceExporter({
|
||||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||||
}),
|
}),
|
||||||
|
instrumentations: [getNodeAutoInstrumentations()],
|
||||||
});
|
});
|
||||||
|
|
||||||
sdk.start();
|
sdk.start();
|
||||||
console.log('Tracer initialized');
|
console.log("Tracer initialized with auto-instrumentations");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shutdownTracer(): Promise<void> {
|
export async function shutdownTracer(): Promise<void> {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"@opentelemetry/sdk-node": "^0.55.0",
|
"@opentelemetry/sdk-node": "^0.55.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0"
|
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.55.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { TextbooksModule } from './textbooks/textbooks.module.js';
|
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({
|
@Module({
|
||||||
imports: [TextbooksModule],
|
imports: [
|
||||||
|
TextbooksModule,
|
||||||
|
ChaptersModule,
|
||||||
|
KnowledgePointsModule,
|
||||||
|
QuestionsModule,
|
||||||
|
HealthModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
61
services/content/src/chapters/chapters.controller.ts
Normal file
61
services/content/src/chapters/chapters.controller.ts
Normal 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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
services/content/src/chapters/chapters.module.ts
Normal file
10
services/content/src/chapters/chapters.module.ts
Normal 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 {}
|
||||||
35
services/content/src/chapters/chapters.repository.ts
Normal file
35
services/content/src/chapters/chapters.repository.ts
Normal 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();
|
||||||
5
services/content/src/chapters/chapters.schema.ts
Normal file
5
services/content/src/chapters/chapters.schema.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
chapters,
|
||||||
|
type Chapter,
|
||||||
|
type NewChapter,
|
||||||
|
} from "../textbooks/textbooks.schema.js";
|
||||||
62
services/content/src/chapters/chapters.service.ts
Normal file
62
services/content/src/chapters/chapters.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,16 @@
|
|||||||
import { drizzle } from 'drizzle-orm/mysql2';
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from "mysql2/promise";
|
||||||
import { env } from './env.js';
|
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() {
|
export const db = drizzle(pool);
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
uri: env.DATABASE_URL,
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return drizzle(pool);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeDb(): Promise<void> {
|
export async function closeDb(): Promise<void> {
|
||||||
if (pool) {
|
await pool.end();
|
||||||
await pool.end();
|
|
||||||
pool = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
PORT: z.string().default('3005'),
|
PORT: z.string().default("3005"),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
REDIS_URL: z.string().url().optional(),
|
REDIS_URL: z.string().url().optional(),
|
||||||
NEO4J_URL: z.string().url(),
|
NEO4J_URL: z.string().url().optional(),
|
||||||
NEO4J_PASSWORD: z.string(),
|
NEO4J_PASSWORD: z.string().optional(),
|
||||||
ES_URL: z.string().url(),
|
ES_URL: z.string().url().optional(),
|
||||||
JWT_SECRET: z.string(),
|
JWT_SECRET: z.string().optional(),
|
||||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
JWT_ISSUER: z.string().default("next-edu-cloud"),
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
LOG_LEVEL: z
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
.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>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
@@ -19,8 +24,11 @@ export type Env = z.infer<typeof envSchema>;
|
|||||||
export function loadEnv(): Env {
|
export function loadEnv(): Env {
|
||||||
const result = envSchema.safeParse(process.env);
|
const result = envSchema.safeParse(process.env);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
console.error(
|
||||||
throw new Error('Invalid environment configuration');
|
"❌ Invalid environment variables:",
|
||||||
|
result.error.flatten().fieldErrors,
|
||||||
|
);
|
||||||
|
throw new Error("Invalid environment configuration");
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
import neo4j from 'neo4j-driver';
|
import neo4j from "neo4j-driver";
|
||||||
import { env } from './env.js';
|
import type { Driver, Session } from "neo4j-driver";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
|
||||||
export const neo4jDriver = neo4j.driver(
|
// Neo4j driver 创建为惰性初始化:未配置 NEO4J_URL / NEO4J_PASSWORD 时
|
||||||
env.NEO4J_URL,
|
// driver 保持 null,服务仍可正常启动。所有依赖 Neo4j 的查询在
|
||||||
neo4j.auth.basic('neo4j', env.NEO4J_PASSWORD)
|
// 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),
|
||||||
|
// 连接超时 3s:Neo4j 不可用时快速失败,避免拖慢 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> {
|
export async function closeNeo4j(): Promise<void> {
|
||||||
await neo4jDriver.close();
|
if (driver) {
|
||||||
|
await driver.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
knowledgePoints,
|
||||||
|
type KnowledgePoint,
|
||||||
|
type NewKnowledgePoint,
|
||||||
|
} from "../textbooks/textbooks.schema.js";
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,54 @@
|
|||||||
import 'reflect-metadata';
|
import "reflect-metadata";
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from "./app.module.js";
|
||||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||||
import { env } from './config/env.js';
|
import { env } from "./config/env.js";
|
||||||
import { logger } from './shared/observability/logger.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> {
|
async function bootstrap(): Promise<void> {
|
||||||
initTracer();
|
initTracer();
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ['log', 'error', 'warn'],
|
logger: ["log", "error", "warn"],
|
||||||
});
|
});
|
||||||
|
|
||||||
app.useGlobalFilters(new GlobalErrorFilter());
|
app.useGlobalFilters(new GlobalErrorFilter());
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
await app.listen(env.PORT);
|
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||||
logger.info({ port: env.PORT }, 'Content service started');
|
// 返回 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);
|
||||||
await app.close();
|
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 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) => {
|
bootstrap().catch((err: unknown) => {
|
||||||
logger.error({ err }, 'Failed to start content service');
|
logger.error({ err }, "Failed to start content service");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
61
services/content/src/questions/questions.controller.ts
Normal file
61
services/content/src/questions/questions.controller.ts
Normal 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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
services/content/src/questions/questions.module.ts
Normal file
10
services/content/src/questions/questions.module.ts
Normal 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 {}
|
||||||
39
services/content/src/questions/questions.repository.ts
Normal file
39
services/content/src/questions/questions.repository.ts
Normal 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();
|
||||||
23
services/content/src/questions/questions.schema.ts
Normal file
23
services/content/src/questions/questions.schema.ts
Normal 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;
|
||||||
83
services/content/src/questions/questions.service.ts
Normal file
83
services/content/src/questions/questions.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
|
import {
|
||||||
import { Request, Response } from 'express';
|
Catch,
|
||||||
import { ZodError } from 'zod';
|
ExceptionFilter,
|
||||||
import { ApplicationError } from './application-error.js';
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import { ApplicationError } from "./application-error.js";
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class GlobalErrorFilter implements ExceptionFilter {
|
export class GlobalErrorFilter implements ExceptionFilter {
|
||||||
@@ -9,10 +14,13 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例,
|
||||||
const request = ctx.getRequest<Request>();
|
// 但 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 statusCode = 500;
|
||||||
let body: Record<string, unknown>;
|
let body: Record<string, unknown>;
|
||||||
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
body = {
|
body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'CONTENT_VALIDATION_ERROR',
|
code: "CONTENT_VALIDATION_ERROR",
|
||||||
message: 'Validation failed',
|
message: "Validation failed",
|
||||||
details: exception.flatten(),
|
details: exception.flatten(),
|
||||||
traceId,
|
traceId,
|
||||||
},
|
},
|
||||||
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
body = {
|
body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'HTTP_ERROR',
|
code: "HTTP_ERROR",
|
||||||
message,
|
message,
|
||||||
traceId,
|
traceId,
|
||||||
},
|
},
|
||||||
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
body = {
|
body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'INTERNAL_ERROR',
|
code: "INTERNAL_ERROR",
|
||||||
message: 'An unexpected error occurred',
|
message: "An unexpected error occurred",
|
||||||
traceId,
|
traceId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
response.status(statusCode).json(body);
|
response.status(statusCode).json(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractHttpMessage(res: string | object, exception: HttpException): string {
|
private extractHttpMessage(
|
||||||
if (typeof res === 'string') {
|
res: string | object,
|
||||||
|
exception: HttpException,
|
||||||
|
): string {
|
||||||
|
if (typeof res === "string") {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
if (res && typeof res === 'object' && 'message' in res) {
|
if (res && typeof res === "object" && "message" in res) {
|
||||||
// 从 HttpException 响应体收窄类型(NestJS 约定包含 message 字段)
|
// 从 HttpException 响应体收窄类型(NestJS 约定包含 message 字段)
|
||||||
const msg = (res as { message: unknown }).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;
|
return exception.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,41 @@
|
|||||||
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||||
import { DataSource } from 'typeorm';
|
import { sql } from "drizzle-orm";
|
||||||
|
import { db } from "../../config/database.js";
|
||||||
|
|
||||||
const SERVICE_NAME = 'content';
|
const SERVICE_NAME = "content";
|
||||||
|
|
||||||
/**
|
|
||||||
* 健康检查端点。
|
|
||||||
*
|
|
||||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
|
||||||
* - GET /readyz:readiness,检查 DB 连接,失败返回 503。
|
|
||||||
*
|
|
||||||
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
|
|
||||||
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
|
|
||||||
*/
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
@Get("healthz")
|
||||||
|
|
||||||
@Get('healthz')
|
|
||||||
liveness(): { status: string; service: string; timestamp: string } {
|
liveness(): { status: string; service: string; timestamp: string } {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: "ok",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('readyz')
|
@Get("readyz")
|
||||||
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
|
async readiness(): Promise<{
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
timestamp: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query('SELECT 1');
|
await db.execute(sql`SELECT 1`);
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: "ok",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
status: 'error',
|
status: "error",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error: error instanceof Error ? error.message : 'database unreachable',
|
error:
|
||||||
|
error instanceof Error ? error.message : "database unreachable",
|
||||||
},
|
},
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { HealthController } from './health.controller';
|
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({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,63 +1,24 @@
|
|||||||
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { DataSource } from 'typeorm';
|
import { closeDb } from "../../config/database.js";
|
||||||
import type { Redis } from 'ioredis';
|
import { closeNeo4j } from "../../config/neo4j.js";
|
||||||
import type { Producer } from 'kafkajs';
|
|
||||||
|
|
||||||
const SERVICE_NAME = 'content';
|
const SERVICE_NAME = "content";
|
||||||
|
|
||||||
/**
|
|
||||||
* 优雅停机服务。
|
|
||||||
*
|
|
||||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
|
||||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 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()
|
@Injectable()
|
||||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
export class LifecycleService {
|
||||||
private readonly logger = new Logger(LifecycleService.name);
|
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> {
|
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||||
this.logger.log(
|
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 {
|
try {
|
||||||
await fn();
|
await closeNeo4j();
|
||||||
this.logger.log(`${name} closed`);
|
await closeDb();
|
||||||
|
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
|
`shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import promClient from 'prom-client';
|
import promClient from "prom-client";
|
||||||
|
|
||||||
const registry = new promClient.Registry();
|
const registry = new promClient.Registry();
|
||||||
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register)
|
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register)
|
||||||
registry.setDefaultLabels({ service: 'content' });
|
registry.setDefaultLabels({ service: "content" });
|
||||||
|
|
||||||
registry.registerMetric(
|
registry.registerMetric(
|
||||||
new promClient.Counter({
|
new promClient.Counter({
|
||||||
name: 'content_requests_total',
|
name: "content_requests_total",
|
||||||
help: 'Total number of content requests',
|
help: "Total number of content requests",
|
||||||
labelNames: ['method', 'endpoint', 'status'],
|
labelNames: ["method", "endpoint", "status"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
registry.registerMetric(
|
registry.registerMetric(
|
||||||
new promClient.Histogram({
|
new promClient.Histogram({
|
||||||
name: 'content_request_duration_seconds',
|
name: "content_request_duration_seconds",
|
||||||
help: 'Content request duration in seconds',
|
help: "Content request duration in seconds",
|
||||||
labelNames: ['method', 'endpoint'],
|
labelNames: ["method", "endpoint"],
|
||||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
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 };
|
export { registry as metricsRegistry };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||||
import { env } from '../../config/env.js';
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||||
|
import { env } from "../../config/env.js";
|
||||||
|
|
||||||
let sdk: NodeSDK | null = null;
|
let sdk: NodeSDK | null = null;
|
||||||
|
|
||||||
@@ -8,14 +9,15 @@ export function initTracer(): void {
|
|||||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
||||||
|
|
||||||
sdk = new NodeSDK({
|
sdk = new NodeSDK({
|
||||||
serviceName: 'content',
|
serviceName: "content",
|
||||||
traceExporter: new OTLPTraceExporter({
|
traceExporter: new OTLPTraceExporter({
|
||||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||||
}),
|
}),
|
||||||
|
instrumentations: [getNodeAutoInstrumentations()],
|
||||||
});
|
});
|
||||||
|
|
||||||
sdk.start();
|
sdk.start();
|
||||||
console.log('Tracer initialized');
|
console.log("Tracer initialized with auto-instrumentations");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shutdownTracer(): Promise<void> {
|
export async function shutdownTracer(): Promise<void> {
|
||||||
|
|||||||
@@ -1,25 +1,59 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import {
|
||||||
import { TextbooksService } from './textbooks.service.js';
|
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 {
|
export class TextbooksController {
|
||||||
constructor(private readonly service: TextbooksService) {}
|
constructor(private readonly service: TextbooksService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() body: unknown) {
|
async create(
|
||||||
const result = await this.service.create(body as any);
|
@Body() body: CreateTextbookInput,
|
||||||
|
): Promise<{ success: true; data: { id: string } }> {
|
||||||
|
const result = await this.service.create(body);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list(): Promise<{ success: true; data: Textbook[] }> {
|
||||||
const result = await this.service.list();
|
const data = await this.service.list();
|
||||||
return { success: true, data: result };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
async getById(@Param('id') id: string) {
|
async getById(
|
||||||
const result = await this.service.getById(id);
|
@Param("id") id: string,
|
||||||
return { success: true, data: result };
|
): 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 } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { TextbooksController } from './textbooks.controller.js';
|
import { TextbooksController } from "./textbooks.controller.js";
|
||||||
import { TextbooksService } from './textbooks.service.js';
|
import { TextbooksService } from "./textbooks.service.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [TextbooksController],
|
controllers: [TextbooksController],
|
||||||
providers: [TextbooksService],
|
providers: [TextbooksService],
|
||||||
|
exports: [TextbooksService],
|
||||||
})
|
})
|
||||||
export class TextbooksModule {}
|
export class TextbooksModule {}
|
||||||
|
|||||||
@@ -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', {
|
export const textbooks = mysqlTable("content_textbooks", {
|
||||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||||
title: varchar('title', { length: 200 }).notNull(),
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
subjectId: char('subject_id', { length: 36 }).notNull(),
|
subjectId: char("subject_id", { length: 36 }).notNull(),
|
||||||
gradeId: char('grade_id', { length: 36 }).notNull(),
|
gradeId: char("grade_id", { length: 36 }).notNull(),
|
||||||
version: varchar('version', { length: 50 }).notNull(),
|
version: varchar("version", { length: 50 }).notNull(),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const chapters = mysqlTable('content_chapters', {
|
export const chapters = mysqlTable("content_chapters", {
|
||||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||||
textbookId: char('textbook_id', { length: 36 }).notNull(),
|
textbookId: char("textbook_id", { length: 36 }).notNull(),
|
||||||
title: varchar('title', { length: 200 }).notNull(),
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
order: integer('order_num').notNull(),
|
order: int("order_num").notNull(),
|
||||||
parentId: char('parent_id', { length: 36 }),
|
parentId: char("parent_id", { length: 36 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const knowledgePoints = mysqlTable('content_knowledge_points', {
|
export const knowledgePoints = mysqlTable("content_knowledge_points", {
|
||||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||||
chapterId: char('chapter_id', { length: 36 }).notNull(),
|
chapterId: char("chapter_id", { length: 36 }).notNull(),
|
||||||
title: varchar('title', { length: 200 }).notNull(),
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
description: text('description'),
|
description: text("description"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Textbook = typeof textbooks.$inferSelect;
|
export type Textbook = typeof textbooks.$inferSelect;
|
||||||
|
export type NewTextbook = typeof textbooks.$inferInsert;
|
||||||
export type Chapter = typeof chapters.$inferSelect;
|
export type Chapter = typeof chapters.$inferSelect;
|
||||||
|
export type NewChapter = typeof chapters.$inferInsert;
|
||||||
export type KnowledgePoint = typeof knowledgePoints.$inferSelect;
|
export type KnowledgePoint = typeof knowledgePoints.$inferSelect;
|
||||||
|
export type NewKnowledgePoint = typeof knowledgePoints.$inferInsert;
|
||||||
|
|||||||
@@ -1,69 +1,70 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { randomUUID } from "node:crypto";
|
||||||
import { getDb } from '../config/database.js';
|
import { eq } from "drizzle-orm";
|
||||||
import { neo4jDriver } from '../config/neo4j.js';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { textbooks, chapters, knowledgePoints } from './textbooks.schema.js';
|
import { db } from "../config/database.js";
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { textbooks, type Textbook } from "./textbooks.schema.js";
|
||||||
import { eq } from 'drizzle-orm';
|
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()
|
@Injectable()
|
||||||
export class TextbooksService {
|
export class TextbooksService {
|
||||||
async create(data: { title: string; subjectId: string; gradeId: string; version: string }) {
|
async create(input: CreateTextbookInput): Promise<{ id: string }> {
|
||||||
const id = uuidv4();
|
if (!input.title || !input.subjectId || !input.gradeId || !input.version) {
|
||||||
const db = getDb();
|
throw new ValidationError(
|
||||||
await db.insert(textbooks).values({ id, ...data });
|
"title, subjectId, gradeId, version are required",
|
||||||
return { id, ...data };
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async list(): Promise<Textbook[]> {
|
||||||
const db = getDb();
|
|
||||||
return db.select().from(textbooks);
|
return db.select().from(textbooks);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string) {
|
async getById(id: string): Promise<Textbook> {
|
||||||
const db = getDb();
|
const [result] = await db
|
||||||
const [result] = await db.select().from(textbooks).where(eq(textbooks.id, id));
|
.select()
|
||||||
|
.from(textbooks)
|
||||||
|
.where(eq(textbooks.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundError("Textbook", id);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 知识图谱:在 Neo4j 中创建知识点节点和关系
|
async update(id: string, data: UpdateTextbookInput): Promise<void> {
|
||||||
async createKnowledgeGraph(knowledgePointId: string, title: string, prerequisiteIds: string[]): Promise<void> {
|
await this.getById(id);
|
||||||
const session = neo4jDriver.session();
|
await db.update(textbooks).set(data).where(eq(textbooks.id, id));
|
||||||
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 delete(id: string): Promise<void> {
|
||||||
async getPrerequisites(knowledgePointId: string): Promise<unknown[]> {
|
await this.getById(id);
|
||||||
const session = neo4jDriver.session();
|
await db.delete(textbooks).where(eq(textbooks.id, id));
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"prom-client": "^15.1.0",
|
"prom-client": "^15.1.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
|
||||||
"@opentelemetry/sdk-node": "^0.53.0",
|
"@opentelemetry/sdk-node": "^0.53.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
||||||
"zod": "^3.23.0",
|
"zod": "^3.23.0",
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { ExamsModule } from './exams/exams.module.js';
|
import { ExamsModule } from "./exams/exams.module.js";
|
||||||
import { HomeworkModule } from './homework/homework.module.js';
|
import { HomeworkModule } from "./homework/homework.module.js";
|
||||||
import { GradesModule } from './grades/grades.module.js';
|
import { GradesModule } from "./grades/grades.module.js";
|
||||||
import { ClassesModule } from './classes/classes.module.js';
|
import { HealthModule } from "./shared/health/health.module.js";
|
||||||
import { AuthMiddleware } from './middleware/auth.middleware.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ExamsModule, HomeworkModule, GradesModule, ClassesModule],
|
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule {}
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
|
||||||
consumer.apply(AuthMiddleware).forRoutes('/api/*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { drizzle } from 'drizzle-orm/mysql2';
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from "mysql2/promise";
|
||||||
import { env } from './env.js';
|
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() {
|
export const db = drizzle(pool);
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
uri: env.DATABASE_URL,
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return drizzle(pool);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeDb(): Promise<void> {
|
export async function closeDb(): Promise<void> {
|
||||||
if (pool) {
|
await pool.end();
|
||||||
await pool.end();
|
|
||||||
pool = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
PORT: z.string().default('3004'),
|
PORT: z.string().default("3004"),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
REDIS_URL: z.string().url().optional(),
|
REDIS_URL: z.string().url().optional(),
|
||||||
JWT_SECRET: z.string(),
|
JWT_SECRET: z.string().optional(),
|
||||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
JWT_ISSUER: z.string().default("next-edu-cloud"),
|
||||||
KAFKA_BROKERS: z.string().default('localhost:9092'),
|
KAFKA_BROKERS: z.string().default("localhost:9092"),
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
LOG_LEVEL: z
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
.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>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
@@ -17,8 +22,11 @@ export type Env = z.infer<typeof envSchema>;
|
|||||||
export function loadEnv(): Env {
|
export function loadEnv(): Env {
|
||||||
const result = envSchema.safeParse(process.env);
|
const result = envSchema.safeParse(process.env);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
console.error(
|
||||||
throw new Error('Invalid environment configuration');
|
"❌ Invalid environment variables:",
|
||||||
|
result.error.flatten().fieldErrors,
|
||||||
|
);
|
||||||
|
throw new Error("Invalid environment configuration");
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import { Kafka } from 'kafkajs';
|
import { Kafka } from "kafkajs";
|
||||||
import { env } from './env.js';
|
import { env } from "./env.js";
|
||||||
|
|
||||||
export const kafka = new Kafka({
|
export const kafka = new Kafka({
|
||||||
brokers: env.KAFKA_BROKERS.split(','),
|
brokers: env.KAFKA_BROKERS.split(","),
|
||||||
clientId: 'core-edu-service',
|
clientId: "core-edu-service",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const producer = kafka.producer({
|
export const producer = kafka.producer({
|
||||||
idempotent: true,
|
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> {
|
export async function connectKafka(): Promise<void> {
|
||||||
await producer.connect();
|
try {
|
||||||
await consumer.connect();
|
await producer.connect();
|
||||||
console.log('Kafka connected');
|
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> {
|
export async function disconnectKafka(): Promise<void> {
|
||||||
|
|||||||
@@ -6,52 +6,64 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { ExamsService, type CreateExamInput, type UpdateExamInput } from './exams.service.js';
|
import type { Request } from "express";
|
||||||
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
|
import {
|
||||||
|
ExamsService,
|
||||||
|
type CreateExamInput,
|
||||||
|
type UpdateExamInput,
|
||||||
|
} from "./exams.service.js";
|
||||||
|
|
||||||
interface SuccessResponse<T> {
|
@Controller("exams")
|
||||||
data: T;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/v1/exams')
|
|
||||||
export class ExamsController {
|
export class ExamsController {
|
||||||
constructor(private readonly examsService: ExamsService) {}
|
constructor(private readonly examsService: ExamsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(new PermissionGuard(Permissions.EXAM_CREATE))
|
async create(
|
||||||
async create(@Body() body: CreateExamInput): Promise<SuccessResponse<{ id: string }>> {
|
@Body() body: CreateExamInput,
|
||||||
const result = await this.examsService.createExam(body);
|
@Req() req: Request,
|
||||||
return { data: result, timestamp: new Date().toISOString() };
|
): 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')
|
@Get(":id")
|
||||||
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
|
async findOne(@Param("id") id: string): Promise<{
|
||||||
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['getExam']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<ExamsService["getExam"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.examsService.getExam(id);
|
const data = await this.examsService.getExam(id);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('class/:classId')
|
@Get("class/:classId")
|
||||||
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
|
async listByClass(@Param("classId") classId: string): Promise<{
|
||||||
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['listExamsByClass']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.examsService.listExamsByClass(classId);
|
const data = await this.examsService.listExamsByClass(classId);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(":id")
|
||||||
@UseGuards(new PermissionGuard(Permissions.EXAM_UPDATE))
|
async update(
|
||||||
async update(@Param('id') id: string, @Body() body: UpdateExamInput): Promise<SuccessResponse<{ success: true }>> {
|
@Param("id") id: string,
|
||||||
|
@Body() body: UpdateExamInput,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
await this.examsService.updateExam(id, body);
|
await this.examsService.updateExam(id, body);
|
||||||
return { data: { success: true }, timestamp: new Date().toISOString() };
|
return { success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(":id")
|
||||||
@UseGuards(new PermissionGuard(Permissions.EXAM_DELETE))
|
async remove(
|
||||||
async remove(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
|
@Param("id") id: string,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
await this.examsService.deleteExam(id);
|
await this.examsService.deleteExam(id);
|
||||||
return { data: { success: true }, timestamp: new Date().toISOString() };
|
return { success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from '../../config/database.js';
|
import { db } from "../config/database.js";
|
||||||
import { exams, type Exam, type NewExam } from './exams.schema.js';
|
import { exams, type Exam, type NewExam } from "./exams.schema.js";
|
||||||
|
|
||||||
export class ExamsRepository {
|
export class ExamsRepository {
|
||||||
async findById(id: string): Promise<Exam | undefined> {
|
async findById(id: string): Promise<Exam | undefined> {
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { db } from '../../config/database.js';
|
import { db } from "../config/database.js";
|
||||||
import { exams } from './exams.schema.js';
|
import { exams } from "./exams.schema.js";
|
||||||
import { examsRepository } from './exams.repository.js';
|
import { examsRepository } from "./exams.repository.js";
|
||||||
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
|
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
|
||||||
import type { Exam, NewExam } from './exams.schema.js';
|
import type { Exam, NewExam } from "./exams.schema.js";
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
export interface CreateExamInput {
|
export interface CreateExamInput {
|
||||||
classId: string;
|
classId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
examDate: Date;
|
examDate: Date | string;
|
||||||
duration: string;
|
duration: string;
|
||||||
totalScore: string;
|
totalScore: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
@@ -31,7 +34,7 @@ export interface UpdateExamInput {
|
|||||||
export class ExamsService {
|
export class ExamsService {
|
||||||
async createExam(input: CreateExamInput): Promise<{ id: string }> {
|
async createExam(input: CreateExamInput): Promise<{ id: string }> {
|
||||||
if (!input.classId || !input.title || !input.createdBy) {
|
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();
|
const id = randomUUID();
|
||||||
@@ -40,10 +43,15 @@ export class ExamsService {
|
|||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description,
|
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,
|
duration: input.duration,
|
||||||
totalScore: input.totalScore,
|
totalScore: input.totalScore,
|
||||||
status: 'draft',
|
status: "draft",
|
||||||
createdBy: input.createdBy,
|
createdBy: input.createdBy,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,14 +61,14 @@ export class ExamsService {
|
|||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: 'exam',
|
aggregateType: "exam",
|
||||||
eventType: 'exam.created',
|
eventType: "exam.created",
|
||||||
payload: JSON.stringify({
|
payload: JSON.stringify({
|
||||||
id,
|
id,
|
||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
}),
|
}),
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -93,10 +101,10 @@ export class ExamsService {
|
|||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: 'exam',
|
aggregateType: "exam",
|
||||||
eventType: 'exam.updated',
|
eventType: "exam.updated",
|
||||||
payload: JSON.stringify({ id, changes: data }),
|
payload: JSON.stringify({ id, changes: data }),
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -115,10 +123,10 @@ export class ExamsService {
|
|||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: 'exam',
|
aggregateType: "exam",
|
||||||
eventType: 'exam.deleted',
|
eventType: "exam.deleted",
|
||||||
payload: JSON.stringify({ id }),
|
payload: JSON.stringify({ id }),
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,55 +1,57 @@
|
|||||||
import {
|
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
||||||
Body,
|
import type { Request } from "express";
|
||||||
Controller,
|
import { GradesService, type RecordGradeInput } from "./grades.service.js";
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { GradesService, type RecordGradeInput } from './grades.service.js';
|
|
||||||
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
|
|
||||||
|
|
||||||
interface SuccessResponse<T> {
|
@Controller("grades")
|
||||||
data: T;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/v1/grades')
|
|
||||||
export class GradesController {
|
export class GradesController {
|
||||||
constructor(private readonly gradesService: GradesService) {}
|
constructor(private readonly gradesService: GradesService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(new PermissionGuard(Permissions.GRADE_CREATE))
|
async record(
|
||||||
async record(@Body() body: RecordGradeInput): Promise<SuccessResponse<{ id: string }>> {
|
@Body() body: RecordGradeInput,
|
||||||
const result = await this.gradesService.recordGrade(body);
|
@Req() req: Request,
|
||||||
return { data: result, timestamp: new Date().toISOString() };
|
): 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')
|
@Get(":id")
|
||||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
async findOne(@Param("id") id: string): Promise<{
|
||||||
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['getGrade']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<GradesService["getGrade"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.gradesService.getGrade(id);
|
const data = await this.gradesService.getGrade(id);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('student/:studentId')
|
@Get("student/:studentId")
|
||||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
async listByStudent(@Param("studentId") studentId: string): Promise<{
|
||||||
async listByStudent(@Param('studentId') studentId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByStudent']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<GradesService["listByStudent"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.gradesService.listByStudent(studentId);
|
const data = await this.gradesService.listByStudent(studentId);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('exam/:examId')
|
@Get("exam/:examId")
|
||||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
async listByExam(@Param("examId") examId: string): Promise<{
|
||||||
async listByExam(@Param('examId') examId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByExam']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<GradesService["listByExam"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.gradesService.listByExam(examId);
|
const data = await this.gradesService.listByExam(examId);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('homework/:homeworkId')
|
@Get("homework/:homeworkId")
|
||||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
|
||||||
async listByHomework(@Param('homeworkId') homeworkId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByHomework']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<GradesService["listByHomework"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.gradesService.listByHomework(homeworkId);
|
const data = await this.gradesService.listByHomework(homeworkId);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { db } from '../../config/database.js';
|
import { db } from "../config/database.js";
|
||||||
import { grades } from './grades.schema.js';
|
import { grades } from "./grades.schema.js";
|
||||||
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
|
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
|
||||||
import type { Grade, NewGrade } from './grades.schema.js';
|
import type { Grade, NewGrade } from "./grades.schema.js";
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
export interface RecordGradeInput {
|
export interface RecordGradeInput {
|
||||||
studentId: string;
|
studentId: string;
|
||||||
@@ -20,10 +23,10 @@ export interface RecordGradeInput {
|
|||||||
export class GradesService {
|
export class GradesService {
|
||||||
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
|
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
|
||||||
if (!input.studentId || !input.score || !input.gradedBy) {
|
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) {
|
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();
|
const id = randomUUID();
|
||||||
@@ -43,8 +46,8 @@ export class GradesService {
|
|||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: 'grade',
|
aggregateType: "grade",
|
||||||
eventType: 'grade.recorded',
|
eventType: "grade.recorded",
|
||||||
payload: JSON.stringify({
|
payload: JSON.stringify({
|
||||||
id,
|
id,
|
||||||
studentId: input.studentId,
|
studentId: input.studentId,
|
||||||
@@ -52,7 +55,7 @@ export class GradesService {
|
|||||||
examId: input.examId,
|
examId: input.examId,
|
||||||
homeworkId: input.homeworkId,
|
homeworkId: input.homeworkId,
|
||||||
}),
|
}),
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +1,50 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
import {
|
import {
|
||||||
Body,
|
HomeworkService,
|
||||||
Controller,
|
type AssignHomeworkInput,
|
||||||
Get,
|
} from "./homework.service.js";
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { HomeworkService, type AssignHomeworkInput } from './homework.service.js';
|
|
||||||
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
|
|
||||||
|
|
||||||
interface SuccessResponse<T> {
|
@Controller("homework")
|
||||||
data: T;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/v1/homework')
|
|
||||||
export class HomeworkController {
|
export class HomeworkController {
|
||||||
constructor(private readonly homeworkService: HomeworkService) {}
|
constructor(private readonly homeworkService: HomeworkService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_CREATE))
|
async assign(
|
||||||
async assign(@Body() body: AssignHomeworkInput): Promise<SuccessResponse<{ id: string }>> {
|
@Body() body: AssignHomeworkInput,
|
||||||
const result = await this.homeworkService.assignHomework(body);
|
@Req() req: Request,
|
||||||
return { data: result, timestamp: new Date().toISOString() };
|
): 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')
|
@Get(":id")
|
||||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
|
async findOne(@Param("id") id: string): Promise<{
|
||||||
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['getHomework']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.homeworkService.getHomework(id);
|
const data = await this.homeworkService.getHomework(id);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('class/:classId')
|
@Get("class/:classId")
|
||||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
|
async listByClass(@Param("classId") classId: string): Promise<{
|
||||||
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['listByClass']>>>> {
|
success: true;
|
||||||
|
data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
|
||||||
|
}> {
|
||||||
const data = await this.homeworkService.listByClass(classId);
|
const data = await this.homeworkService.listByClass(classId);
|
||||||
return { data, timestamp: new Date().toISOString() };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(":id/submit")
|
||||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_SUBMIT))
|
async submit(
|
||||||
async submit(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
|
@Param("id") id: string,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
await this.homeworkService.submitHomework(id);
|
await this.homeworkService.submitHomework(id);
|
||||||
return { data: { success: true }, timestamp: new Date().toISOString() };
|
return { success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { db } from '../../config/database.js';
|
import { db } from "../config/database.js";
|
||||||
import { homework } from './homework.schema.js';
|
import { homework } from "./homework.schema.js";
|
||||||
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
|
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
|
||||||
import type { Homework, NewHomework } from './homework.schema.js';
|
import type { Homework, NewHomework } from "./homework.schema.js";
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
export interface AssignHomeworkInput {
|
export interface AssignHomeworkInput {
|
||||||
classId: string;
|
classId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
dueDate: Date;
|
dueDate: Date | string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +22,7 @@ export interface AssignHomeworkInput {
|
|||||||
export class HomeworkService {
|
export class HomeworkService {
|
||||||
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
|
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
|
||||||
if (!input.classId || !input.title || !input.createdBy) {
|
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();
|
const id = randomUUID();
|
||||||
@@ -28,8 +31,10 @@ export class HomeworkService {
|
|||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
dueDate: input.dueDate,
|
// Drizzle datetime 列需要 Date 对象;HTTP 请求体里 dueDate 是 ISO 字符串。
|
||||||
status: 'assigned',
|
dueDate:
|
||||||
|
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
|
||||||
|
status: "assigned",
|
||||||
createdBy: input.createdBy,
|
createdBy: input.createdBy,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,14 +44,14 @@ export class HomeworkService {
|
|||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: 'homework',
|
aggregateType: "homework",
|
||||||
eventType: 'homework.assigned',
|
eventType: "homework.assigned",
|
||||||
payload: JSON.stringify({
|
payload: JSON.stringify({
|
||||||
id,
|
id,
|
||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
}),
|
}),
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -73,23 +78,23 @@ export class HomeworkService {
|
|||||||
|
|
||||||
async submitHomework(id: string): Promise<void> {
|
async submitHomework(id: string): Promise<void> {
|
||||||
const existing = await this.getHomework(id);
|
const existing = await this.getHomework(id);
|
||||||
if (existing.status === 'submitted') {
|
if (existing.status === "submitted") {
|
||||||
throw new ValidationError(`Homework ${id} already submitted`);
|
throw new ValidationError(`Homework ${id} already submitted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
.update(homework)
|
.update(homework)
|
||||||
.set({ status: 'submitted' })
|
.set({ status: "submitted" })
|
||||||
.where(eq(homework.id, id));
|
.where(eq(homework.id, id));
|
||||||
await outboxRepository.create(
|
await outboxRepository.create(
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: 'homework',
|
aggregateType: "homework",
|
||||||
eventType: 'homework.submitted',
|
eventType: "homework.submitted",
|
||||||
payload: JSON.stringify({ id }),
|
payload: JSON.stringify({ id }),
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from "./app.module.js";
|
||||||
import { env } from './config/env.js';
|
import { env } from "./config/env.js";
|
||||||
import { connectKafka, disconnectKafka } from './config/kafka.js';
|
import { connectKafka, disconnectKafka } from "./config/kafka.js";
|
||||||
import { outboxPublisher } from './shared/outbox/outbox.publisher.js';
|
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
|
||||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||||
import { logger } from './shared/observability/logger.js';
|
import { logger } from "./shared/observability/logger.js";
|
||||||
|
import { registry } from "./shared/observability/metrics.js";
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
initTracer();
|
initTracer();
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
app.setGlobalPrefix('api');
|
|
||||||
app.useGlobalFilters(new GlobalErrorFilter());
|
app.useGlobalFilters(new GlobalErrorFilter());
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
// Connect Kafka producer/consumer before starting the outbox publisher
|
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||||
await connectKafka();
|
// 返回 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
|
// Start the transactional outbox publisher - polls pending messages
|
||||||
// and publishes them to Kafka topics defined in TOPIC_MAP.
|
// and publishes them to Kafka topics defined in TOPIC_MAP.
|
||||||
@@ -24,12 +33,12 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
await app.listen(env.PORT);
|
await app.listen(env.PORT);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ port: env.PORT, service: 'core-edu' },
|
{ port: env.PORT, service: "core-edu" },
|
||||||
'CoreEdu service is listening',
|
"CoreEdu service is listening",
|
||||||
);
|
);
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on("SIGTERM", async () => {
|
||||||
logger.info('SIGTERM received, shutting down gracefully...');
|
logger.info("SIGTERM received, shutting down gracefully...");
|
||||||
await outboxPublisher.stop();
|
await outboxPublisher.stop();
|
||||||
await disconnectKafka();
|
await disconnectKafka();
|
||||||
await shutdownTracer();
|
await shutdownTracer();
|
||||||
@@ -37,8 +46,8 @@ async function bootstrap(): Promise<void> {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
process.on("SIGINT", async () => {
|
||||||
logger.info('SIGINT received, shutting down gracefully...');
|
logger.info("SIGINT received, shutting down gracefully...");
|
||||||
await outboxPublisher.stop();
|
await outboxPublisher.stop();
|
||||||
await disconnectKafka();
|
await disconnectKafka();
|
||||||
await shutdownTracer();
|
await shutdownTracer();
|
||||||
|
|||||||
@@ -1,46 +1,41 @@
|
|||||||
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||||
import { DataSource } from 'typeorm';
|
import { sql } from "drizzle-orm";
|
||||||
|
import { db } from "../../config/database.js";
|
||||||
|
|
||||||
const SERVICE_NAME = 'core-edu';
|
const SERVICE_NAME = "core-edu";
|
||||||
|
|
||||||
/**
|
|
||||||
* 健康检查端点。
|
|
||||||
*
|
|
||||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
|
||||||
* - GET /readyz:readiness,检查 DB 连接,失败返回 503。
|
|
||||||
*
|
|
||||||
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
|
|
||||||
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
|
|
||||||
*/
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
@Get("healthz")
|
||||||
|
|
||||||
@Get('healthz')
|
|
||||||
liveness(): { status: string; service: string; timestamp: string } {
|
liveness(): { status: string; service: string; timestamp: string } {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: "ok",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('readyz')
|
@Get("readyz")
|
||||||
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
|
async readiness(): Promise<{
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
timestamp: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query('SELECT 1');
|
await db.execute(sql`SELECT 1`);
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: "ok",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
status: 'error',
|
status: "error",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error: error instanceof Error ? error.message : 'database unreachable',
|
error:
|
||||||
|
error instanceof Error ? error.message : "database unreachable",
|
||||||
},
|
},
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { HealthController } from './health.controller';
|
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({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,62 +1,22 @@
|
|||||||
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { DataSource } from 'typeorm';
|
import { closeDb } from "../../config/database.js";
|
||||||
import type { Redis } from 'ioredis';
|
|
||||||
import type { Producer } from 'kafkajs';
|
|
||||||
|
|
||||||
const SERVICE_NAME = 'core-edu';
|
const SERVICE_NAME = "core-edu";
|
||||||
|
|
||||||
/**
|
|
||||||
* 优雅停机服务。
|
|
||||||
*
|
|
||||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
|
||||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 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()
|
@Injectable()
|
||||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
export class LifecycleService {
|
||||||
private readonly logger = new Logger(LifecycleService.name);
|
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> {
|
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||||
this.logger.log(
|
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 {
|
try {
|
||||||
await fn();
|
await closeDb();
|
||||||
this.logger.log(`${name} closed`);
|
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
|
`shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||||
import { env } from '../../config/env.js';
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||||
import { logger } from './logger.js';
|
import { env } from "../../config/env.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
let sdk: NodeSDK | undefined;
|
let sdk: NodeSDK | undefined;
|
||||||
|
|
||||||
export function initTracer(): void {
|
export function initTracer(): void {
|
||||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
||||||
logger.warn('OTEL_EXPORTER_OTLP_ENDPOINT not set, tracing disabled');
|
logger.warn("OTEL_EXPORTER_OTLP_ENDPOINT not set, tracing disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sdk = new NodeSDK({
|
sdk = new NodeSDK({
|
||||||
serviceName: 'core-edu',
|
serviceName: "core-edu",
|
||||||
traceExporter: new OTLPTraceExporter({
|
traceExporter: new OTLPTraceExporter({
|
||||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||||
}),
|
}),
|
||||||
|
instrumentations: [getNodeAutoInstrumentations()],
|
||||||
});
|
});
|
||||||
sdk.start();
|
sdk.start();
|
||||||
logger.info('OpenTelemetry tracer initialized');
|
logger.info("OpenTelemetry tracer initialized with auto-instrumentations");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shutdownTracer(): Promise<void> {
|
export async function shutdownTracer(): Promise<void> {
|
||||||
if (sdk) {
|
if (sdk) {
|
||||||
await sdk.shutdown();
|
await sdk.shutdown();
|
||||||
sdk = undefined;
|
sdk = undefined;
|
||||||
logger.info('OpenTelemetry tracer shutdown');
|
logger.info("OpenTelemetry tracer shutdown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.5.0",
|
"pydantic-settings>=2.5.0",
|
||||||
"opentelemetry-api>=1.27.0",
|
"opentelemetry-api>=1.27.0",
|
||||||
"opentelemetry-sdk>=1.27.0",
|
"opentelemetry-sdk>=1.27.0",
|
||||||
|
"opentelemetry-exporter-otlp>=1.27.0",
|
||||||
"opentelemetry-instrumentation-fastapi>=0.48b0",
|
"opentelemetry-instrumentation-fastapi>=0.48b0",
|
||||||
"prometheus-client>=0.20.0",
|
"prometheus-client>=0.20.0",
|
||||||
"structlog>=24.4.0",
|
"structlog>=24.4.0",
|
||||||
|
# CDC 链路:消费 Debezium 写入 Kafka 的 MySQL binlog 变更事件
|
||||||
|
"aiokafka>=0.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
233
services/data-ana/src/data_ana/cdc_consumer.py
Normal file
233
services/data-ana/src/data_ana/cdc_consumer.py
Normal 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:
|
||||||
|
"""安全转 float(Debezium 数值字段可能是字符串)."""
|
||||||
|
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/u:after 为新数据,写入宽表
|
||||||
|
- op=d:after 为 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))
|
||||||
@@ -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
|
from .config import settings
|
||||||
|
|
||||||
_client = None
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
_client: Any | None = None
|
||||||
|
# 标记是否已尝试初始化(避免对失败连接反复重试)
|
||||||
|
_client_initialized: bool = False
|
||||||
|
|
||||||
|
|
||||||
def get_client():
|
def get_client() -> Any | None:
|
||||||
"""获取 ClickHouse 客户端."""
|
"""获取 ClickHouse 客户端.
|
||||||
global _client
|
|
||||||
if _client is None:
|
- 当 clickhouse_host 为空:返回 None(降级模式)
|
||||||
_client = clickhouse_connect.get_client(
|
- 当已初始化但失败:返回 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,
|
host=settings.clickhouse_host,
|
||||||
port=settings.clickhouse_port,
|
port=settings.clickhouse_port,
|
||||||
database=settings.clickhouse_database,
|
database=settings.clickhouse_database,
|
||||||
)
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# 任何初始化异常都进入降级模式,不抛出
|
||||||
|
logger.warning("clickhouse_client_init_failed_degraded", error=str(exc))
|
||||||
|
_client = None
|
||||||
|
|
||||||
return _client
|
return _client
|
||||||
|
|
||||||
|
|
||||||
async def close_client() -> None:
|
async def close_client() -> None:
|
||||||
"""关闭客户端."""
|
"""关闭客户端."""
|
||||||
global _client
|
global _client, _client_initialized
|
||||||
if _client:
|
if _client is not None:
|
||||||
_client.close()
|
try:
|
||||||
_client = None
|
_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
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
"""配置管理."""
|
"""配置管理."""
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""应用配置."""
|
"""应用配置.
|
||||||
|
|
||||||
|
ClickHouse 连接参数为可选:当 clickhouse_host 为空字符串时,
|
||||||
|
服务进入降级模式(查询方法返回 None / 空数据),保证服务可启动。
|
||||||
|
|
||||||
|
Kafka 连接参数为可选:当 kafka_brokers 为空字符串时,
|
||||||
|
CDC 消费者不启动(降级模式),保证服务可启动。
|
||||||
|
"""
|
||||||
|
|
||||||
port: int = 3006
|
port: int = 3006
|
||||||
clickhouse_host: str = "localhost"
|
# ClickHouse 连接(可选:留空则降级模式)
|
||||||
|
clickhouse_host: str = ""
|
||||||
clickhouse_port: int = 8123
|
clickhouse_port: int = 8123
|
||||||
clickhouse_database: str = "edu_analytics"
|
clickhouse_database: str = "edu_analytics"
|
||||||
|
clickhouse_user: str = ""
|
||||||
|
clickhouse_password: str = ""
|
||||||
|
# 可观测性
|
||||||
otel_endpoint: str = "http://localhost:4318"
|
otel_endpoint: str = "http://localhost:4318"
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
|
# 开发模式开关("true"/"false")
|
||||||
|
dev_mode: str = "false"
|
||||||
|
# Kafka brokers(CDC 消费;留空则不启动消费者)
|
||||||
|
# 主机访问用 localhost:9092,容器内访问用 kafka:29092
|
||||||
|
kafka_brokers: str = ""
|
||||||
|
# CDC 消费组 id
|
||||||
|
kafka_group_id: str = "data-ana-cdc-consumer"
|
||||||
|
# 要消费的 CDC topic(Debezium 默认命名:<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": ""}
|
model_config = {"env_file": ".env", "env_prefix": ""}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,124 @@
|
|||||||
"""数据分析服务入口."""
|
"""数据分析服务入口.
|
||||||
|
|
||||||
|
支持 ClickHouse 降级模式:当 CLICKHOUSE_HOST 未配置或不可达时,
|
||||||
|
查询端点返回骨架数据,服务仍可启动与响应。
|
||||||
|
|
||||||
|
支持 CDC 消费者:当 KAFKA_BROKERS 配置时,
|
||||||
|
后台启动 aiokafka 消费者,监听 Debezium CDC 事件写入 ClickHouse。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
from prometheus_client import make_asgi_app
|
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__)
|
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:
|
def init_tracer() -> None:
|
||||||
"""初始化 OpenTelemetry."""
|
"""初始化 OpenTelemetry.
|
||||||
|
|
||||||
|
endpoint 从 settings.otel_endpoint 读取(不硬编码)。
|
||||||
|
"""
|
||||||
provider = TracerProvider()
|
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))
|
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||||
trace.set_tracer_provider(provider)
|
trace.set_tracer_provider(provider)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""应用生命周期."""
|
"""应用生命周期.
|
||||||
|
|
||||||
|
1. 初始化 logger(structlog)
|
||||||
|
2. 初始化 OTel tracer(endpoint 从 config 读)
|
||||||
|
3. 触发 ClickHouse 客户端惰性初始化(不阻塞启动,失败进入降级模式)
|
||||||
|
4. 若配置了 kafka_brokers,后台启动 CDC 消费者任务
|
||||||
|
5. 关闭时停止 CDC 任务并释放 ClickHouse 客户端
|
||||||
|
"""
|
||||||
|
global _cdc_task
|
||||||
|
logger = init_logger()
|
||||||
init_tracer()
|
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_brokers,run_consumer 内部直接返回)
|
||||||
|
_cdc_task = asyncio.create_task(run_cdc_consumer())
|
||||||
yield
|
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(
|
app = FastAPI(
|
||||||
@@ -37,41 +127,166 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# OpenTelemetry FastAPI 自动埋点(HTTP 请求/响应 span)
|
||||||
|
FastAPIInstrumentor.instrument_app(app)
|
||||||
|
|
||||||
# Prometheus 指标
|
# Prometheus 指标
|
||||||
app.mount("/metrics", make_asgi_app())
|
app.mount("/metrics", make_asgi_app())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz():
|
async def healthz() -> dict:
|
||||||
"""健康检查."""
|
"""健康检查(liveness).
|
||||||
|
|
||||||
|
只要进程存活即返回 ok,不依赖 ClickHouse。
|
||||||
|
"""
|
||||||
return {"status": "ok", "service": "data-ana"}
|
return {"status": "ok", "service": "data-ana"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analytics/class/{class_id}/performance")
|
@app.get("/readyz")
|
||||||
async def class_performance(class_id: str):
|
async def readyz() -> dict:
|
||||||
"""班级成绩分析."""
|
"""就绪检查(readiness).
|
||||||
with tracer.start_as_current_span("class_performance"):
|
|
||||||
# P4 骨架:从 ClickHouse 查询分析数据
|
ClickHouse 为可选依赖:
|
||||||
|
- 已配置且可达:ready=true
|
||||||
|
- 未配置:ready=true,degraded=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 {
|
return {
|
||||||
"success": True,
|
"status": "ok",
|
||||||
"data": {
|
"service": "data-ana",
|
||||||
"classId": class_id,
|
"ready": True,
|
||||||
"averageScore": 0,
|
"degraded": True,
|
||||||
"passRate": 0,
|
"clickhouse": "not_configured",
|
||||||
"message": "P4 skeleton - ClickHouse integration pending",
|
"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")
|
@app.get("/analytics/student/{student_id}/weakness")
|
||||||
async def student_weakness(student_id: str):
|
async def student_weakness(student_id: str) -> dict:
|
||||||
"""学生薄弱知识点分析."""
|
"""学生薄弱知识点分析.
|
||||||
with tracer.start_as_current_span("student_weakness"):
|
|
||||||
|
优先查 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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"data": {
|
"data": {
|
||||||
"studentId": student_id,
|
"studentId": student_id,
|
||||||
"weakPoints": [],
|
"weakPoints": weak_points,
|
||||||
"message": "P4 skeleton - weakness analysis pending",
|
"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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user