feat(core-edu): 完整实现 core-edu 教学核心服务

包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:08:56 +08:00
parent 06a646ea4e
commit 58c0ba1bd9
55 changed files with 4204 additions and 305 deletions

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
const envSchema = z.object({
PORT: z.string().default("3004"),
GRPC_PORT: z.string().default("50053"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().optional(),
@@ -23,7 +24,7 @@ export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error(
"Invalid environment variables:",
"Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment configuration");

View File

@@ -1,5 +1,6 @@
import { Kafka } from "kafkajs";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
export const kafka = new Kafka({
brokers: env.KAFKA_BROKERS.split(","),
@@ -13,15 +14,35 @@ export const producer = kafka.producer({
export const consumer = kafka.consumer({ groupId: "core-edu-group" });
let producerConnected = false;
let consumerConnected = false;
producer.on("producer.connect", () => {
producerConnected = true;
});
producer.on("producer.disconnect", () => {
producerConnected = false;
});
consumer.on("consumer.connect", () => {
consumerConnected = true;
});
consumer.on("consumer.disconnect", () => {
consumerConnected = false;
});
export function isKafkaConnected(): boolean {
return producerConnected && consumerConnected;
}
export async function connectKafka(): Promise<void> {
try {
await producer.connect();
await consumer.connect();
console.log("Kafka connected");
logger.info("Kafka connected");
} catch (err) {
console.warn(
"Kafka connect failed, running without Kafka:",
err instanceof Error ? err.message : String(err),
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"Kafka connect failed, running without Kafka",
);
}
}

View File

@@ -0,0 +1,97 @@
import { createClient, type RedisClientType } from "redis";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
let client: RedisClientType | null = null;
/**
* 获取 Redis 客户端(单例)。
* REDIS_URL 未配置时返回 null调用方需做 null 检查。
*/
export function getRedisClient(): RedisClientType | null {
if (!env.REDIS_URL) return null;
if (!client) {
client = createClient({ url: env.REDIS_URL });
client.on("error", (err) => {
logger.error({ err: err.message }, "Redis client error");
});
}
return client;
}
/**
* 连接 Redis非阻塞连接失败不影响服务启动
*/
export async function connectRedis(): Promise<void> {
const c = getRedisClient();
if (!c) {
logger.warn("REDIS_URL not set, Redis disabled");
return;
}
if (c.isOpen) return;
try {
await c.connect();
logger.info("Redis connected");
} catch (err) {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"Redis connect failed, running without Redis",
);
}
}
/**
* 关闭 Redis 连接。
*/
export async function disconnectRedis(): Promise<void> {
if (client && client.isOpen) {
await client.quit();
logger.info("Redis disconnected");
}
}
/**
* Redis 健康检查(用于 /readyz
*/
export async function isRedisHealthy(): Promise<boolean> {
if (!client || !client.isOpen) return false;
try {
const pong = await client.ping();
return pong === "PONG";
} catch {
return false;
}
}
/**
* 分布式锁(用于作业提交幂等性保护)。
*
* 使用 SET NX EX 实现:
* - 成功获取锁返回 true
* - 锁已被持有返回 false
*
* 锁自动过期TTL避免死锁。
*/
export async function acquireLock(
key: string,
ttlSeconds: number,
): Promise<boolean> {
const c = getRedisClient();
if (!c || !c.isOpen) return false;
try {
const result = await c.set(key, "1", { NX: true, EX: ttlSeconds });
return result === "OK";
} catch {
return false;
}
}
export async function releaseLock(key: string): Promise<void> {
const c = getRedisClient();
if (!c || !c.isOpen) return;
try {
await c.del(key);
} catch {
// 释放失败不影响业务(锁会自动过期)
}
}