docs: ai 协作文档体系重构与多 ai 仲裁结果落地

1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md)

2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration)

3.各服务 01/02 文档补全

4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens)

5.Proto 契约补全

6.004 架构影响地图更新

7.端口分配表

8.设计规格文档
This commit is contained in:
SpecialX
2026-07-10 12:58:22 +08:00
parent 2a2a56f541
commit faaaf29f67
120 changed files with 23201 additions and 2 deletions

View File

@@ -0,0 +1,24 @@
{
"name": "@edu/hooks",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/ui-components": "workspace:*"
},
"peerDependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,62 @@
/**
* @edu/hooks - 共享 Hooks 库
*
* 维护者ai13teacher-portal
* 关联文档teacher-portal 02-architecture-design.md §8
*
* 包含:
* - useAuth会话状态user/token/login/logout/refresh
* - usePermission权限查询hasPermission/hasAny/hasAll + dataScope
* - useViewports视口列表按 scope 过滤)
* - useApiApiClient 实例(注入 token + 401 处理 + trace_id
* - useA11yId唯一 ARIA ID 生成
* - useAriaLivearia-live 区域管理
* - useToast全局 toast 通知
* - useTraceId当前会话 trace_id
*
* 设计原则:
* - hooks 不直接调 API保持纯客户端逻辑API 调用由 apps/* 注入)
* - 权限/视口数据通过 props 注入,避免 hooks 包耦合 iam
* - token 存储使用 localStorageF12 裁决P2 阶段)
*/
export { useAuth } from "./use-auth.js";
export type { UseAuthReturn } from "./use-auth.js";
export { usePermission } from "./use-permission.js";
export type {
UsePermissionProps,
UsePermissionReturn,
} from "./use-permission.js";
export { useViewports } from "./use-viewports.js";
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports.js";
export { useApi } from "./use-api.js";
export type { UseApiProps, UseApiReturn } from "./use-api.js";
export {
useA11yId,
useA11yIds,
mergeA11yProps,
describeInput,
} from "./use-a11y-id.js";
export { useAriaLive } from "./use-aria-live.js";
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live.js";
export { useToast } from "./use-toast.js";
export type { UseToastReturn } from "./use-toast.js";
export { useTraceId } from "./use-trace-id.js";
export type { UseTraceIdReturn } from "./use-trace-id.js";
// 共享类型
export type {
UserSession,
DataScope,
PermissionContext,
Viewport,
ToastMessage,
AuthState,
} from "./types.js";

View File

@@ -0,0 +1,68 @@
/**
* 共享类型定义hooks 包内部使用)
*
* 维护者ai13teacher-portal
*/
/**
* 用户会话信息
*/
export interface UserSession {
userId: string;
username: string;
displayName: string;
avatarUrl?: string;
roles: string[];
/** 数据范围DataScope 6 级) */
dataScope?: DataScope;
}
/**
* 数据范围(对齐 iam DataScope 6 级)
*/
export type DataScope =
"ALL" | "SCHOOL" | "GRADE" | "CLASS" | "SUBJECT" | "SELF";
/**
* 权限点检查结果
*/
export interface PermissionContext {
/** 当前用户拥有的权限点列表 */
permissions: string[];
/** 当前用户的数据范围 */
dataScope: DataScope;
/** 当前用户的角色列表 */
roles: string[];
}
/**
* 视口Viewport- iam 4 层视口模型
*/
export interface Viewport {
id: string;
name: string;
type: "admin" | "teacher" | "student" | "parent";
scope: DataScope;
}
/**
* 通知消息Toast
*/
export interface ToastMessage {
id: string;
type: "success" | "error" | "warning" | "info";
title: string;
description?: string;
/** 自动关闭时长ms0 表示不自动关闭 */
duration?: number;
}
/**
* 鉴权状态
*/
export interface AuthState {
user: UserSession | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}

View File

@@ -0,0 +1,119 @@
import { useMemo, useState } from "react";
/**
* useA11yId - 唯一 ARIA ID 生成器
*
* 用途:为表单元素生成 aria-labelledby / aria-describedby 关联的唯一 ID。
* 迁移自 CICD 项目 A11y 工具集。
*
* @example
* const id = useA11yId("email-input");
* <input id={id} aria-describedby={`${id}-error`} />
* <span id={`${id}-error`}>{errorMessage}</span>
*/
let idCounter = 0;
/**
* 生成唯一 ID带可选前缀
* 使用计数器 + 随机数,避免 SSR/CSR hydration mismatch。
*/
export function useA11yId(prefix?: string): string {
const [id] = useState(() => {
idCounter += 1;
const random = Math.random().toString(36).slice(2, 8);
const base = prefix
? `${prefix}-${idCounter}-${random}`
: `a11y-${idCounter}-${random}`;
return base;
});
return id;
}
/**
* 批量生成关联 ID用于表单 input + label + error + description 关联)
*
* @example
* const ids = useA11yIds("email");
* // ids = { input: "email-input-1-xxx", label: "email-label-1-xxx", error: "email-error-1-xxx", description: "email-description-1-xxx" }
*/
export function useA11yIds(prefix: string): {
input: string;
label: string;
error: string;
description: string;
} {
const inputId = useA11yId(`${prefix}-input`);
const labelId = useA11yId(`${prefix}-label`);
const errorId = useA11yId(`${prefix}-error`);
const descriptionId = useA11yId(`${prefix}-description`);
return useMemo(
() => ({
input: inputId,
label: labelId,
error: errorId,
description: descriptionId,
}),
[inputId, labelId, errorId, descriptionId],
);
}
/**
* 合并 ARIA 属性(覆盖优先级:后者覆盖前者)
*
* @example
* mergeA11yProps({ "aria-label": "默认" }, { "aria-label": "自定义" })
* // => { "aria-label": "自定义" }
*/
export function mergeA11yProps(
...props: Array<Record<string, unknown> | undefined>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const prop of props) {
if (prop) {
Object.assign(result, prop);
}
}
return result;
}
/**
* 描述输入框的 ARIA 属性
*
* @example
* const a11y = describeInput({ label: "邮箱", required: true, error: "邮箱格式错误", description: "请输入工作邮箱" });
* <input {...a11y} />
*/
export function describeInput(options: {
label: string;
required?: boolean;
error?: string;
description?: string;
invalid?: boolean;
}): Record<string, unknown> {
const { label, required, error, description, invalid } = options;
const props: Record<string, unknown> = {
"aria-label": label,
};
if (required) {
props["aria-required"] = true;
}
if (invalid || error) {
props["aria-invalid"] = true;
}
// aria-describedby 由调用方拼接(需关联 error/description 的 ID
const describedBy: string[] = [];
if (description) describedBy.push("description");
if (error) describedBy.push("error");
if (describedBy.length > 0) {
props["aria-describedby"] = describedBy.join(" ");
}
return props;
}

View File

@@ -0,0 +1,90 @@
import { useCallback, useMemo } from "react";
/**
* useApi - ApiClient 实例管理
*
* 用途:注入 token + 401 处理 + trace_id 透传。
* P2 阶段GraphQL clienturql/apollo注入本 hook 提供统一 fetcher 封装。
*
* 注意:本 hook 不直接创建 GraphQL client由 Shell 的 GraphQLProvider 注入)。
* 本 hook 提供"API 调用层"封装,用于非 GraphQL 场景(如文件上传 / SSE 流)。
*
* @example
* const { fetchJson, fetchWithAuth } = useApi({ token, traceId });
* const data = await fetchJson("/api/v1/upload", { method: "POST", body: formData });
*/
export interface UseApiProps {
/** 认证 token从 useAuth 获取) */
token: string | null;
/** trace_id从 useTraceId 获取) */
traceId?: string | null;
/** 401 回调(通常跳转登录页) */
onUnauthorized?: () => void;
}
export interface UseApiReturn {
/** 带 auth + trace_id 的 fetch 封装 */
fetchWithAuth: (url: string, init?: RequestInit) => Promise<Response>;
/** fetchJson - 解析 JSON 响应 */
fetchJson: <T>(url: string, init?: RequestInit) => Promise<T>;
/** 构造带 auth 的 headers */
authHeaders: Record<string, string>;
}
export function useApi({
token,
traceId,
onUnauthorized,
}: UseApiProps): UseApiReturn {
const authHeaders = useMemo(() => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
if (traceId) {
headers["X-Trace-Id"] = traceId;
}
return headers;
}, [token, traceId]);
const fetchWithAuth = useCallback(
async (url: string, init?: RequestInit): Promise<Response> => {
const mergedInit: RequestInit = {
...init,
headers: {
...authHeaders,
...(init?.headers ?? {}),
},
};
const response = await fetch(url, mergedInit);
if (response.status === 401) {
onUnauthorized?.();
}
return response;
},
[authHeaders, onUnauthorized],
);
const fetchJson = useCallback(
async <T>(url: string, init?: RequestInit): Promise<T> => {
const response = await fetchWithAuth(url, init);
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
},
[fetchWithAuth],
);
return {
fetchWithAuth,
fetchJson,
authHeaders,
};
}

View File

@@ -0,0 +1,52 @@
/**
* useAriaLive - aria-live 区域管理
*
* 用途:动态向 aria-live 区域推送消息,供屏幕阅读器播报。
* 迁移自 CICD 项目 A11y 工具集。
*
* @example
* const { message, announce } = useAriaLive();
* announce("表单提交成功", "polite");
* // 渲染:<div aria-live="polite">{message}</div>
*/
import { useCallback, useState } from "react";
export type AriaLivePoliteness = "polite" | "assertive" | "off";
export interface UseAriaLiveReturn {
/** 当前消息内容 */
message: string;
/** 当前 politeness 级别 */
politeness: AriaLivePoliteness;
/** 推送消息 */
announce: (message: string, politeness?: AriaLivePoliteness) => void;
/** 清除消息 */
clear: () => void;
}
export function useAriaLive(): UseAriaLiveReturn {
const [message, setMessage] = useState("");
const [politeness, setPoliteness] = useState<AriaLivePoliteness>("polite");
const announce = useCallback(
(msg: string, level: AriaLivePoliteness = "polite"): void => {
setPoliteness(level);
// 先清空再设置,确保相同消息也能触发播报
setMessage("");
requestAnimationFrame(() => setMessage(msg));
},
[],
);
const clear = useCallback((): void => {
setMessage("");
}, []);
return {
message,
politeness,
announce,
clear,
};
}

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useState } from "react";
import type { AuthState, UserSession } from "./types.js";
/**
* useAuth - 会话状态管理
*
* 用途管理用户登录状态、token、用户信息。
* P2 阶段token 存储在 localStorageF12 裁决P6 评估迁移 httpOnly Cookie。
*
* 注意:本 hook 提供"状态管理"骨架,具体的登录/登出 API 调用
* 由 apps/* 注入(通过 AuthProvider Context 或 props 传入 fetcher
*
* @example
* const { user, isAuthenticated, login, logout } = useAuth();
*/
const TOKEN_KEY = "edu_auth_token";
const USER_KEY = "edu_auth_user";
export interface UseAuthReturn extends AuthState {
/** 登录(由调用方注入 fetcher */
login: (token: string, user: UserSession) => void;
/** 登出 */
logout: () => void;
/** 刷新用户信息 */
refreshUser: (user: UserSession) => void;
}
export function useAuth(): UseAuthReturn {
const [state, setState] = useState<AuthState>({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
});
// 初始化:从 localStorage 读取 token + userF12 裁决 P2 用 localStorage
useEffect(() => {
try {
const token = localStorage.getItem(TOKEN_KEY);
const userJson = localStorage.getItem(USER_KEY);
const user = userJson ? (JSON.parse(userJson) as UserSession) : null;
setState({
user,
token,
isAuthenticated: Boolean(token && user),
isLoading: false,
});
} catch {
setState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
}
}, []);
const login = useCallback((token: string, user: UserSession): void => {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
setState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
}, []);
const logout = useCallback((): void => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
}, []);
const refreshUser = useCallback((user: UserSession): void => {
localStorage.setItem(USER_KEY, JSON.stringify(user));
setState((prev) => ({ ...prev, user }));
}, []);
return {
...state,
login,
logout,
refreshUser,
};
}

View File

@@ -0,0 +1,76 @@
import { useCallback, useMemo } from "react";
import type { PermissionContext } from "./types.js";
/**
* usePermission - 权限查询 Hook
*
* 用途L2 路由级 + L3 组件级视口控制。
* 权限点命名遵循 F7 裁决:`<RESOURCE>_<ACTION>`(如 `EXAM_READ`)。
* 数据范围用后缀 `_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`)。
*
* 注意权限数据由调用方注入apps/* 从 iam /iam/permissions/effective 获取后传入)。
* 本 hook 不直接调 API保持纯客户端逻辑。
*
* @example
* const { hasPermission, hasAny, hasAll } = usePermission({ permissions, dataScope, roles });
*
* if (hasPermission("EXAM_CREATE")) { ... }
* if (hasPermission("GRADE_READ_CHILD")) { ... } // 数据范围后缀
*/
export interface UsePermissionProps {
/** 权限上下文(从 iam /iam/permissions/effective 获取) */
context: PermissionContext | null;
}
export interface UsePermissionReturn {
/** 检查是否拥有指定权限点 */
hasPermission: (perm: string) => boolean;
/** 检查是否拥有任一权限点 */
hasAny: (...perms: string[]) => boolean;
/** 检查是否拥有全部权限点 */
hasAll: (...perms: string[]) => boolean;
/** 检查是否拥有指定角色 */
hasRole: (role: string) => boolean;
/** 当前数据范围 */
dataScope: PermissionContext["dataScope"] | null;
}
export function usePermission({
context,
}: UsePermissionProps): UsePermissionReturn {
const permissions = context?.permissions ?? [];
const roles = context?.roles ?? [];
const dataScope = context?.dataScope ?? null;
const permissionSet = useMemo(() => new Set(permissions), [permissions]);
const roleSet = useMemo(() => new Set(roles), [roles]);
const hasPermission = useCallback(
(perm: string): boolean => permissionSet.has(perm),
[permissionSet],
);
const hasAny = useCallback(
(...perms: string[]): boolean => perms.some((p) => permissionSet.has(p)),
[permissionSet],
);
const hasAll = useCallback(
(...perms: string[]): boolean => perms.every((p) => permissionSet.has(p)),
[permissionSet],
);
const hasRole = useCallback(
(role: string): boolean => roleSet.has(role),
[roleSet],
);
return {
hasPermission,
hasAny,
hasAll,
hasRole,
dataScope,
};
}

View File

@@ -0,0 +1,66 @@
import { useCallback, useState } from "react";
import type { ToastMessage } from "./types.js";
/**
* useToast - 全局 Toast 通知管理
*
* 用途:成功/错误/警告/信息提示,自动关闭。
* 与 Zustand ui-store 解耦,提供 hook 层 API。
*
* @example
* const { toasts, showToast, dismissToast } = useToast();
* showToast({ type: "success", title: "保存成功" });
*/
export interface UseToastReturn {
/** 当前活跃的 toast 列表 */
toasts: ToastMessage[];
/** 展示 toast */
showToast: (toast: Omit<ToastMessage, "id">) => string;
/** 关闭指定 toast */
dismissToast: (id: string) => void;
/** 关闭全部 toast */
dismissAll: () => void;
}
export function useToast(): UseToastReturn {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const dismissToast = useCallback((id: string): void => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const showToast = useCallback(
(toast: Omit<ToastMessage, "id">): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const fullToast: ToastMessage = {
id,
duration: 4000,
...toast,
};
setToasts((prev) => [...prev, fullToast]);
// 自动关闭
if (fullToast.duration && fullToast.duration > 0) {
setTimeout(() => {
dismissToast(id);
}, fullToast.duration);
}
return id;
},
[dismissToast],
);
const dismissAll = useCallback((): void => {
setToasts([]);
}, []);
return {
toasts,
showToast,
dismissToast,
dismissAll,
};
}

View File

@@ -0,0 +1,61 @@
import { useCallback, useState } from "react";
/**
* useTraceId - 当前会话 trace_id 管理
*
* 用途OpenTelemetry traceparent 透传,用于日志关联 + 链路追踪。
* ai13 新增(对齐 project_rules §12 可观测性规范)。
*
* @example
* const { traceId, setTraceId, withTraceId } = useTraceId();
* setTraceId("abc123");
* withTraceId(() => fetch("/api/v1/exams", { headers: { "X-Trace-Id": traceId } }));
*/
export interface UseTraceIdReturn {
/** 当前 trace_id */
traceId: string | null;
/** 设置 trace_id从响应头 X-Trace-Id 获取) */
setTraceId: (id: string) => void;
/** 清除 trace_id */
clear: () => void;
/**
* 在 trace_id 上下文中执行回调(自动注入 header
* 用于 fetch 包装
*/
withTraceId: <T>(
fn: (headers: Record<string, string>) => Promise<T>,
) => Promise<T>;
}
export function useTraceId(): UseTraceIdReturn {
const [traceId, setTraceIdState] = useState<string | null>(null);
const setTraceId = useCallback((id: string): void => {
setTraceIdState(id);
}, []);
const clear = useCallback((): void => {
setTraceIdState(null);
}, []);
const withTraceId = useCallback(
async <T>(
fn: (headers: Record<string, string>) => Promise<T>,
): Promise<T> => {
const headers: Record<string, string> = {};
if (traceId) {
headers["X-Trace-Id"] = traceId;
}
return fn(headers);
},
[traceId],
);
return {
traceId,
setTraceId,
clear,
withTraceId,
};
}

View File

@@ -0,0 +1,65 @@
import { useMemo } from "react";
import type { Viewport } from "./types.js";
/**
* useViewports - 视口列表查询
*
* 用途:按 scope 过滤当前用户的可用视口iam 4 层视口模型)。
* 视口数据由调用方注入apps/* 从 iam /iam/viewports 获取后传入)。
*
* iam 视口 4 层admin / teacher / student / parent
*
* @example
* const { viewports, getByType, hasViewport } = useViewports({ viewports });
* const teacherViewports = getByType("teacher");
*/
export interface UseViewportsProps {
/** 当前用户的视口列表(从 iam 获取) */
viewports: Viewport[] | null;
}
export interface UseViewportsReturn {
/** 全部视口 */
viewports: Viewport[];
/** 按类型过滤 */
getByType: (type: Viewport["type"]) => Viewport[];
/** 检查是否拥有指定类型视口 */
hasViewport: (type: Viewport["type"]) => boolean;
/** 按 ID 查找 */
getById: (id: string) => Viewport | undefined;
}
export function useViewports({
viewports: input,
}: UseViewportsProps): UseViewportsReturn {
const viewports = useMemo(() => input ?? [], [input]);
const getByType = useMemo(
() =>
(type: Viewport["type"]): Viewport[] =>
viewports.filter((v) => v.type === type),
[viewports],
);
const hasViewport = useMemo(
() =>
(type: Viewport["type"]): boolean =>
viewports.some((v) => v.type === type),
[viewports],
);
const getById = useMemo(
() =>
(id: string): Viewport | undefined =>
viewports.find((v) => v.id === id),
[viewports],
);
return {
viewports,
getByType,
hasViewport,
getById,
};
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

76
packages/shared-go/env/env.go vendored Normal file
View File

@@ -0,0 +1,76 @@
// Package env provides typed helpers for reading environment variables.
//
// All functions treat an unset variable and an empty string as distinct only
// when explicitly documented: Must/Get distinguish "not set" from "set to empty"
// via os.LookupEnv, while GetInt/GetBool/GetDuration fall back to the default
// value when parsing fails.
package env
import (
"fmt"
"os"
"strconv"
"time"
)
// Must returns the value of the environment variable named by key.
// It panics if the variable is not set, which is intended for values that the
// service cannot start without (DB URLs, JWT secrets, ...).
func Must(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
panic(fmt.Sprintf("env: required environment variable %q is not set", key))
}
return v
}
// Get returns the value of the environment variable named by key, or
// defaultValue when the variable is not set.
func Get(key, defaultValue string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return defaultValue
}
// GetInt returns the integer value of the environment variable named by key,
// or defaultValue when the variable is not set or cannot be parsed as an int.
func GetInt(key string, defaultValue int) int {
if v, ok := os.LookupEnv(key); ok {
n, err := strconv.Atoi(v)
if err != nil {
return defaultValue
}
return n
}
return defaultValue
}
// GetBool returns the boolean value of the environment variable named by key,
// or defaultValue when the variable is not set or cannot be parsed as a bool
// (accepted values are those understood by strconv.ParseBool: 1, t, T, TRUE,
// true, True, 0, f, F, FALSE, false, False).
func GetBool(key string, defaultValue bool) bool {
if v, ok := os.LookupEnv(key); ok {
b, err := strconv.ParseBool(v)
if err != nil {
return defaultValue
}
return b
}
return defaultValue
}
// GetDuration returns the duration value of the environment variable named by
// key, or defaultValue when the variable is not set or cannot be parsed by
// time.ParseDuration (e.g. "30s", "5m", "2h").
func GetDuration(key string, defaultValue time.Duration) time.Duration {
if v, ok := os.LookupEnv(key); ok {
d, err := time.ParseDuration(v)
if err != nil {
return defaultValue
}
return d
}
return defaultValue
}

View File

@@ -0,0 +1,2 @@
# buf generate 产物输出到此目录(见 packages/shared-proto/buf.gen.yaml
# 保留此文件以便 gen/proto 目录在产物生成前被 git 跟踪。

View File

@@ -0,0 +1,8 @@
module github.com/edu-cloud/shared-go
go 1.22
require (
go.uber.org/zap v1.27.0
github.com/go-chi/chi/v5 v5.0.12
)

View File

@@ -0,0 +1,183 @@
// Package jwks fetches JSON Web Key Sets and validates RS256 JWTs against them.
//
// A Fetcher caches parsed RSA public keys in memory for 5 minutes per kid so
// repeated token validation does not re-hit the issuer's JWKS endpoint. It is
// safe for concurrent use.
package jwks
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// cacheTTL is how long a cached public key is considered fresh before the
// Fetcher re-fetches the JWKS document.
const cacheTTL = 5 * time.Minute
// Claims is the set of assertions carried by an Edu access token. The
// embedded RegisteredClaims provides exp, iss, aud and the other standard
// JWT claims.
type Claims struct {
UserID string `json:"user_id"`
Role string `json:"role"`
DataScope string `json:"data_scope"`
jwt.RegisteredClaims
}
// cachedKey holds a parsed public key together with the moment it expires.
type cachedKey struct {
key *rsa.PublicKey
expiresAt time.Time
}
// Fetcher downloads and caches a JWKS document and validates tokens against
// the cached keys.
type Fetcher struct {
jwksURL string
httpClient *http.Client
cache sync.Map
}
// NewFetcher returns a Fetcher that retrieves keys from jwksURL.
func NewFetcher(jwksURL string) *Fetcher {
return &Fetcher{
jwksURL: jwksURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// GetPublicKey returns the RSA public key identified by kid. The key is served
// from the in-memory cache when fresh; otherwise the JWKS document is
// re-fetched and the cache repopulated.
func (f *Fetcher) GetPublicKey(kid string) (*rsa.PublicKey, error) {
if v, ok := f.cache.Load(kid); ok {
if ck, ok := v.(*cachedKey); ok && time.Now().Before(ck.expiresAt) {
return ck.key, nil
}
}
if err := f.refreshCache(); err != nil {
return nil, err
}
v, ok := f.cache.Load(kid)
if !ok {
return nil, fmt.Errorf("jwks: kid %q not found in JWKS", kid)
}
ck, ok := v.(*cachedKey)
if !ok {
return nil, fmt.Errorf("jwks: cached entry for kid %q has unexpected type", kid)
}
return ck.key, nil
}
// refreshCache downloads the JWKS document and replaces every cached key.
func (f *Fetcher) refreshCache() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.jwksURL, nil)
if err != nil {
return fmt.Errorf("jwks: building request: %w", err)
}
resp, err := f.httpClient.Do(req)
if err != nil {
return fmt.Errorf("jwks: fetching JWKS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jwks: unexpected status %d from %s", resp.StatusCode, f.jwksURL)
}
var body struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return fmt.Errorf("jwks: decoding response: %w", err)
}
expiresAt := time.Now().Add(cacheTTL)
for _, k := range body.Keys {
if k.Kty != "RSA" {
continue
}
pk, err := buildRSAPublicKey(k.N, k.E)
if err != nil {
return fmt.Errorf("jwks: parsing key %q: %w", k.Kid, err)
}
f.cache.Store(k.Kid, &cachedKey{key: pk, expiresAt: expiresAt})
}
return nil
}
// buildRSAPublicKey reconstructs an rsa.PublicKey from the base64url-encoded
// modulus (n) and exponent (e) fields of a JWK.
func buildRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
if err != nil {
return nil, fmt.Errorf("decoding modulus: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
if err != nil {
return nil, fmt.Errorf("decoding exponent: %w", err)
}
var e int
for _, b := range eBytes {
e = e<<8 + int(b)
}
if e == 0 {
return nil, fmt.Errorf("empty exponent")
}
return &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: e,
}, nil
}
// ValidateToken parses and validates an RS256 JWT using the cached JWKS keys.
// The kid in the token header selects the verification key. On success the
// populated Claims (including user_id, role, data_scope and the standard
// registered claims) are returned.
func (f *Fetcher) ValidateToken(token string) (*Claims, error) {
parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("jwks: unexpected signing method %v", t.Header["alg"])
}
kid, ok := t.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("jwks: token header missing kid")
}
pk, err := f.GetPublicKey(kid)
if err != nil {
return nil, err
}
return pk, nil
})
if err != nil {
return nil, fmt.Errorf("jwks: parsing token: %w", err)
}
claims, ok := parsed.Claims.(*Claims)
if !ok || !parsed.Valid {
return nil, fmt.Errorf("jwks: invalid token claims")
}
return claims, nil
}

View File

@@ -0,0 +1,65 @@
// Package logger provides a structured logger built on zap with OpenTelemetry
// trace correlation.
//
// In production (the default) logs are emitted as JSON at Info level. When the
// ENV environment variable is set to "development" the logger switches to a
// console encoder at Debug level for human-readable output.
package logger
import (
"context"
"os"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// ctxKey is an unexported type so context values cannot collide with callers.
type ctxKey struct{}
// fallback is returned by FromContext when no logger has been injected into
// the context; it discards all output so callers never receive a nil logger.
var fallback = zap.NewNop()
// New creates a structured logger tagged with serviceName. The encoder and
// level are selected from the ENV environment variable as described in the
// package documentation.
func New(serviceName string) *zap.Logger {
var cfg zap.Config
if os.Getenv("ENV") == "development" {
cfg = zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
} else {
cfg = zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
}
l, err := cfg.Build(zap.Fields(zap.String("service", serviceName)))
if err != nil {
// A logger that cannot be constructed is a fatal misconfiguration;
// there is no safe way to continue.
panic(err)
}
return l
}
// WithContext returns a copy of ctx that carries l so it can later be
// retrieved via FromContext.
func WithContext(ctx context.Context, l *zap.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
// FromContext returns the logger stored in ctx. When an active OpenTelemetry
// span is present, the returned logger is decorated with a trace_id field so
// log lines can be correlated to traces. If no logger was stored in ctx a
// no-op logger is returned.
func FromContext(ctx context.Context) *zap.Logger {
if v, ok := ctx.Value(ctxKey{}).(*zap.Logger); ok && v != nil {
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
return v.With(zap.String("trace_id", sc.TraceID().String()))
}
return v
}
return fallback
}

View File

@@ -0,0 +1,92 @@
// Package tracer bootstraps the OpenTelemetry SDK for a service and exports
// traces over OTLP/HTTP (default endpoint localhost:4318, overridable via the
// OTEL_EXPORTER_OTLP_ENDPOINT environment variable).
//
// Typical usage:
//
// if err := tracer.Init("api-gateway"); err != nil { log.Fatal(err) }
// defer tracer.Shutdown(context.Background())
package tracer
import (
"context"
"fmt"
"os"
"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.24.0"
"go.opentelemetry.io/otel/trace"
)
// tp is the process-wide TracerProvider created by Init and torn down by
// Shutdown.
var tp *sdktrace.TracerProvider
// Init initializes the OpenTelemetry SDK with an OTLP HTTP exporter and
// registers it as the global TracerProvider. The service is identified by
// serviceName on all exported spans.
func Init(serviceName string) error {
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if endpoint == "" {
endpoint = "localhost:4318"
}
exp, err := otlptracehttp.New(
context.Background(),
otlptracehttp.WithEndpoint(endpoint),
otlptracehttp.WithInsecure(),
)
if err != nil {
return fmt.Errorf("tracer: creating OTLP exporter: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
),
)
if err != nil {
return fmt.Errorf("tracer: building resource: %w", err)
}
tp = sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return nil
}
// Shutdown flushes pending spans and releases resources held by the
// TracerProvider. It is a no-op when Init was never called. The provided
// context bounds the flush to 5 seconds.
func Shutdown(ctx context.Context) error {
if tp == nil {
return nil
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := tp.Shutdown(ctx); err != nil {
return fmt.Errorf("tracer: shutting down provider: %w", err)
}
return nil
}
// FromContext returns the active span from ctx. When no span is active the
// returned span is a no-op, so callers may safely record attributes or events
// without nil checks.
func FromContext(ctx context.Context) trace.Span {
return trace.SpanFromContext(ctx)
}

View File

@@ -0,0 +1,30 @@
{
"name": "@edu/shared-ts",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./outbox": {
"types": "./src/outbox/index.ts",
"default": "./src/outbox/index.ts"
}
},
"scripts": {
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@paralleldrive/cuid2": "^2.2.2",
"drizzle-orm": "^0.31.0",
"kafkajs": "^2.2.0",
"pino": "^9.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,22 @@
export { OutboxModule } from "./outbox.module.js";
export type { OutboxRootOptions } from "./outbox.module.js";
export { OutboxService } from "./outbox.service.js";
export { OutboxPublisher } from "./publisher.js";
export { createOutboxTable } from "./schema.js";
export type { OutboxTable, OutboxRow, NewOutboxRow } from "./schema.js";
export {
OUTBOX_CONFIG,
OUTBOX_DB,
OUTBOX_KAFKA_PRODUCER,
OUTBOX_LOGGER,
OUTBOX_TABLE,
} from "./types.js";
export type {
OutboxConfig,
OutboxDbClient,
OutboxKafkaProducer,
OutboxLogger,
OutboxRecord,
OutboxStatus,
PublishOptions,
} from "./types.js";

View File

@@ -0,0 +1,75 @@
import { Module, type DynamicModule } from "@nestjs/common";
import pino from "pino";
import { OutboxService } from "./outbox.service.js";
import { OutboxPublisher } from "./publisher.js";
import { createOutboxTable } from "./schema.js";
import {
OUTBOX_CONFIG,
OUTBOX_DB,
OUTBOX_KAFKA_PRODUCER,
OUTBOX_LOGGER,
OUTBOX_TABLE,
type OutboxConfig,
type OutboxDbClient,
type OutboxKafkaProducer,
type OutboxLogger,
} from "./types.js";
/**
* forRoot 选项。
*
* `config` 即 OutboxConfig表名 / topic / 轮询参数)。
* `db` / `kafkaProducer` / `logger` 为基础设施实例NestJS DynamicModule
* 无法跨模块边界注入宿主已存在的具体单例,故统一通过 forRoot 注入。
*
* `kafkaProducer` 应由调用方以 idempotent 方式创建:
* `kafka.producer({ idempotent: true, transactionalId: '<service>-tx' })`。
*/
export interface OutboxRootOptions {
config: OutboxConfig;
db: OutboxDbClient;
kafkaProducer: OutboxKafkaProducer;
/** 可选;省略时使用默认 pino loggername=outbox */
logger?: OutboxLogger;
}
function resolveLogger(logger: OutboxLogger | undefined): OutboxLogger {
return logger ?? pino({ name: "outbox", level: "info" });
}
/**
* OutboxModule —— 事务性 Outbox 模式的 NestJS DynamicModule。
*
* 注册并启动:
* - OutboxService业务代码注入后调用 `publish` 写入 outbox 记录
* - OutboxPublisheronModuleInit 启动轮询onModuleDestroy 停止轮询
*
* 用法:
* ```ts
* @Module({
* imports: [OutboxModule.forRoot({ config, db, kafkaProducer })],
* })
* export class AppModule {}
* ```
*/
@Module({})
export class OutboxModule {
static forRoot(options: OutboxRootOptions): DynamicModule {
const table = createOutboxTable(options.config.tableName);
const logger = resolveLogger(options.logger);
return {
module: OutboxModule,
providers: [
{ provide: OUTBOX_CONFIG, useValue: options.config },
{ provide: OUTBOX_DB, useValue: options.db },
{ provide: OUTBOX_TABLE, useValue: table },
{ provide: OUTBOX_KAFKA_PRODUCER, useValue: options.kafkaProducer },
{ provide: OUTBOX_LOGGER, useValue: logger },
OutboxService,
OutboxPublisher,
],
exports: [OutboxService],
};
}
}

View File

@@ -0,0 +1,74 @@
import { createId } from "@paralleldrive/cuid2";
import { Inject, Injectable } from "@nestjs/common";
import type { NewOutboxRow, OutboxTable } from "./schema.js";
import {
OUTBOX_CONFIG,
OUTBOX_DB,
OUTBOX_LOGGER,
OUTBOX_TABLE,
type OutboxConfig,
type OutboxDbClient,
type OutboxLogger,
type PublishOptions,
} from "./types.js";
/**
* OutboxService —— 事务性 Outbox 写入服务。
*
* 调用方在业务事务内调用 `publish`,将事件记录写入 outbox 表,
* 与业务写在同一事务中原子提交,保证“业务变更”与“事件发布”的一致性。
*
* 由独立的 OutboxPublisher 负责异步轮询 pending 记录并投递到 Kafka
* 实现 at-least-once 投递语义。
*/
@Injectable()
export class OutboxService {
constructor(
@Inject(OUTBOX_DB) private readonly db: OutboxDbClient,
@Inject(OUTBOX_TABLE) private readonly table: OutboxTable,
@Inject(OUTBOX_CONFIG) private readonly config: OutboxConfig,
@Inject(OUTBOX_LOGGER) private readonly logger: OutboxLogger,
) {}
/**
* 写入一条 outbox 记录。
*
* @param eventType 事件类型(如 `ExamCreated`),用于 Kafka topic 路由
* @param payload 事件负载,将被 JSON 序列化存入 outbox 行
* @param options 可选aggregateId / metadata / delayMs / tx
* @returns event idcuid2供调用方追踪同时作为 Kafka 消息 key 实现幂等去重
*
* 事务管理:传入 `options.tx` 则在调用方事务内写入(推荐,保证与业务表原子提交);
* 省略时使用模块注入的 db 执行单条插入(仅保证 outbox 行自身的原子性)。
*/
async publish(
eventType: string,
payload: unknown,
options?: PublishOptions,
): Promise<string> {
const id = createId();
const now = new Date();
const nextRetryAt =
options?.delayMs !== undefined && options.delayMs > 0
? new Date(now.getTime() + options.delayMs)
: null;
const record: NewOutboxRow = {
id,
eventType,
payload: JSON.stringify(payload),
aggregateId: options?.aggregateId ?? id,
maxRetryCount: this.config.maxRetryCount,
nextRetryAt,
metadata: options?.metadata ?? null,
};
await (options?.tx ?? this.db).insert(this.table).values(record);
this.logger.debug(
{ id, eventType, aggregateId: record.aggregateId },
"Outbox record enqueued",
);
return id;
}
}

View File

@@ -0,0 +1,205 @@
import {
Inject,
Injectable,
type OnModuleDestroy,
type OnModuleInit,
} from "@nestjs/common";
import { and, eq, isNull, lte, or, type SQL } from "drizzle-orm";
import type { OutboxRow, OutboxTable } from "./schema.js";
import {
OUTBOX_CONFIG,
OUTBOX_DB,
OUTBOX_KAFKA_PRODUCER,
OUTBOX_LOGGER,
OUTBOX_TABLE,
type OutboxConfig,
type OutboxDbClient,
type OutboxKafkaProducer,
type OutboxLogger,
} from "./types.js";
/**
* OutboxPublisher —— 轮询 pending 记录并投递到 Kafka。
*
* 实现 at-least-once 投递语义:
* 1. 周期性pollIntervalMs批量拉取 status=pending 且到期的记录
* 2. 逐条投递到 Kafkamessage key = event id保证同聚合有序
* 3. 投递成功 → 标记 published
* 4. 投递失败 → 指数退避更新 nextRetryAt重试耗尽 → 标记 failed
*
* 幂等性:
* - producer 应由调用方配置为 idempotent`kafka.producer({ idempotent: true, transactionalId })`
* 避免生产端重试产生重复消息
* - message key = event idcuid2消费端可基于 event_id 去重Redis SETNX 或 DB 唯一索引)
*/
@Injectable()
export class OutboxPublisher implements OnModuleInit, OnModuleDestroy {
private intervalId: ReturnType<typeof setInterval> | null = null;
private isPolling = false;
constructor(
@Inject(OUTBOX_DB) private readonly db: OutboxDbClient,
@Inject(OUTBOX_TABLE) private readonly table: OutboxTable,
@Inject(OUTBOX_KAFKA_PRODUCER)
private readonly producer: OutboxKafkaProducer,
@Inject(OUTBOX_CONFIG) private readonly config: OutboxConfig,
@Inject(OUTBOX_LOGGER) private readonly logger: OutboxLogger,
) {}
async onModuleInit(): Promise<void> {
await this.start();
}
async onModuleDestroy(): Promise<void> {
await this.stop();
}
async start(): Promise<void> {
if (this.intervalId) return;
this.logger.info(
{
topic: this.config.kafkaTopic,
pollIntervalMs: this.config.pollIntervalMs,
batchSize: this.config.batchSize,
},
"OutboxPublisher started",
);
this.intervalId = setInterval(() => {
void this.poll();
}, this.config.pollIntervalMs);
}
async stop(): Promise<void> {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.logger.info("OutboxPublisher stopped");
}
/**
* 拉取一批到期 pending 记录并逐条投递。
* 通过 isPolling 标志保证不会并发轮询。
*/
private async poll(): Promise<void> {
if (this.isPolling) return;
this.isPolling = true;
try {
const now = new Date();
const where: SQL | undefined = and(
eq(this.table.status, "pending"),
or(isNull(this.table.nextRetryAt), lte(this.table.nextRetryAt, now)),
);
const messages: OutboxRow[] = await this.db
.select()
.from(this.table)
.where(where)
.limit(this.config.batchSize);
for (const message of messages) {
await this.dispatch(message);
}
} catch (error) {
this.logger.error({ error }, "Outbox poll failed");
} finally {
this.isPolling = false;
}
}
/**
* 投递单条记录到 Kafka 并更新状态。
*/
private async dispatch(message: OutboxRow): Promise<void> {
try {
await this.producer.send({
topic: this.config.kafkaTopic,
messages: [
{
key: message.id,
value: message.payload,
headers: this.buildHeaders(message),
},
],
});
await this.db
.update(this.table)
.set({ status: "published", publishedAt: new Date(), lastError: null })
.where(eq(this.table.id, message.id));
this.logger.info(
{
id: message.id,
eventType: message.eventType,
topic: this.config.kafkaTopic,
},
"Outbox message published",
);
} catch (error) {
await this.handleFailure(message, this.toErrorMessage(error));
}
}
/**
* 失败处理:指数退避更新 nextRetryAt重试耗尽则标记 failed。
*/
private async handleFailure(
message: OutboxRow,
errorMessage: string,
): Promise<void> {
const newRetryCount = message.retryCount + 1;
this.logger.error(
{
id: message.id,
eventType: message.eventType,
retryCount: newRetryCount,
error: errorMessage,
},
"Outbox publish failed",
);
if (newRetryCount >= message.maxRetryCount) {
await this.db
.update(this.table)
.set({
status: "failed",
retryCount: newRetryCount,
lastError: errorMessage,
})
.where(eq(this.table.id, message.id));
return;
}
const backoffMs = this.config.retryBackoffMs * 2 ** newRetryCount;
const nextRetryAt = new Date(Date.now() + backoffMs);
await this.db
.update(this.table)
.set({
retryCount: newRetryCount,
nextRetryAt,
lastError: errorMessage,
})
.where(eq(this.table.id, message.id));
}
/**
* 构建 Kafka 消息头eventId / eventType / aggregateId + 调用方 metadata。
*/
private buildHeaders(message: OutboxRow): Record<string, string> {
const headers: Record<string, string> = {
eventId: message.id,
eventType: message.eventType,
aggregateId: message.aggregateId,
};
if (message.metadata) {
for (const [key, value] of Object.entries(message.metadata)) {
headers[key] = value;
}
}
return headers;
}
private toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}

View File

@@ -0,0 +1,56 @@
import {
int,
json,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
import type { OutboxRecord, OutboxStatus } from "./types.js";
/**
* 创建 outbox 表的 Drizzle schema。
*
* 表名由调用方通过 OutboxConfig.tableName 提供(每服务独立 outbox 表),
* 因此以工厂函数形式返回,避免多服务共用同一表名常量。
*
* 字段与 OutboxRecord 接口一一对齐:
* - idcuid2varchar(32)),作为 event id 与 Kafka 消息 key
* - status通过 $type<OutboxStatus> 收窄为联合类型
* - metadata通过 $type<Record<string,string> | null> 收窄
*/
export function createOutboxTable(tableName: string) {
return mysqlTable(tableName, {
id: varchar("id", { length: 32 }).notNull().primaryKey(),
eventType: varchar("event_type", { length: 100 }).notNull(),
payload: text("payload").notNull(),
aggregateId: varchar("aggregate_id", { length: 32 }).notNull(),
status: varchar("status", { length: 20 })
.notNull()
.default("pending")
.$type<OutboxStatus>(),
retryCount: int("retry_count").notNull().default(0),
maxRetryCount: int("max_retry_count").notNull().default(5),
nextRetryAt: timestamp("next_retry_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
lastError: text("last_error"),
metadata: json("metadata").$type<Record<string, string> | null>(),
});
}
/** outbox 表类型 */
export type OutboxTable = ReturnType<typeof createOutboxTable>;
/** select 推断行类型(应结构兼容 OutboxRecord */
export type OutboxRow = OutboxTable["$inferSelect"];
/** insert 推断类型 */
export type NewOutboxRow = OutboxTable["$inferInsert"];
/**
* 编译期断言OutboxRow 与 OutboxRecord 结构兼容。
* 若 schema 与接口偏离,此处会在严格模式下编译失败。
*/
const _assertOutboxRow: OutboxRow extends OutboxRecord ? true : never = true;
void _assertOutboxRow;

View File

@@ -0,0 +1,87 @@
import type { MySql2Database } from "drizzle-orm/mysql2";
import type { Producer } from "kafkajs";
import type { Logger } from "pino";
/**
* Outbox 记录状态机:
* - pending已写入 outbox 表,等待 publisher 投递
* - published已成功投递到 Kafka
* - failed重试次数耗尽需人工介入
*/
export type OutboxStatus = "pending" | "published" | "failed";
/**
* Outbox 模块配置(由各服务在 forRoot 时提供)。
*/
export interface OutboxConfig {
/** outbox 表名(每服务独立,如 core_edu_outbox */
tableName: string;
/** Kafka 投递目标 topic */
kafkaTopic: string;
/** 轮询 pending 记录的间隔(毫秒) */
pollIntervalMs: number;
/** 每次轮询批量处理的记录数 */
batchSize: number;
/** 单条记录最大重试次数 */
maxRetryCount: number;
/** 重试退避基数(毫秒),实际退避 = retryBackoffMs * 2^retryCount */
retryBackoffMs: number;
}
/**
* publish 选项。
*
* `tx` 用于由调用方管理事务:调用方在 `db.transaction(async (tx) => { ... })`
* 中同时写入业务表与 outbox 记录,保证两者原子提交(事务性 Outbox 模式)。
*/
export interface PublishOptions {
/** 聚合根 ID作为 Kafka 消息 key 用于分区与顺序保证 */
aggregateId?: string;
/** 附加元数据,写入 outbox 行并随消息头投递 */
metadata?: Record<string, string>;
/** 延迟投递毫秒数;设置后 nextRetryAt 推迟,到期前不会被轮询 */
delayMs?: number;
/** 调用方管理的事务客户端;省略时使用模块注入的 db非事务单条插入 */
tx?: OutboxDbClient;
}
/**
* Outbox 行结构(与 schema.ts 的 $inferSelect 对齐)。
*/
export interface OutboxRecord {
id: string;
eventType: string;
/** JSON 序列化后的 payload */
payload: string;
aggregateId: string;
status: OutboxStatus;
retryCount: number;
maxRetryCount: number;
nextRetryAt: Date | null;
createdAt: Date;
publishedAt: Date | null;
lastError: string | null;
metadata: Record<string, string> | null;
}
/**
* Drizzle 数据库客户端类型。
*
* 服务以无 schema 绑定方式创建 db`drizzle(pool)`),其类型为
* `MySql2Database<Record<string, never>>`。由于 `MySql2Transaction extends
* MySqlDatabase`,事务客户端同样可赋值给本类型,故 publish 可接受调用方传入的 tx。
*/
export type OutboxDbClient = MySql2Database<Record<string, never>>;
/** Kafka producer 类型别名 */
export type OutboxKafkaProducer = Producer;
/** pino Logger 类型别名 */
export type OutboxLogger = Logger;
// —— NestJS 注入 Token —— //
export const OUTBOX_CONFIG = Symbol("OUTBOX_CONFIG");
export const OUTBOX_DB = Symbol("OUTBOX_DB");
export const OUTBOX_TABLE = Symbol("OUTBOX_TABLE");
export const OUTBOX_KAFKA_PRODUCER = Symbol("OUTBOX_KAFKA_PRODUCER");
export const OUTBOX_LOGGER = Symbol("OUTBOX_LOGGER");

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"rootDir": "./src",
"incremental": false,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,24 @@
{
"name": "@edu/ui-components",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/ui-tokens": "workspace:*"
},
"peerDependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,72 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
/**
* Empty - 空态展示(插画占位 + 文案 + CTA
*
* 用途:数据列表为空时展示友好提示,引导用户操作。
*
* @example
* <Empty title="暂无班级" description="点击下方按钮创建第一个班级" action={<Button>新建班级</Button>} />
*/
export interface EmptyProps {
/** 标题(必填) */
title: string;
/** 描述文案 */
description?: string;
/** 行动按钮CTA */
action?: ReactNode;
/** 自定义插画;未提供时使用默认占位 */
illustration?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function Empty({
title,
description,
action,
illustration,
className,
}: EmptyProps): ReactNode {
return (
<div
role="status"
className={cn(
"flex flex-col items-center justify-center gap-4 p-8 text-center",
className,
)}
>
{illustration ?? <DefaultIllustration />}
<div className="flex flex-col gap-1">
<h3 className="text-base font-medium">{title}</h3>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action ?? null}
</div>
);
}
function DefaultIllustration(): ReactNode {
return (
<div
className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="h-8 w-8 text-muted-foreground"
>
<path d="M9 17a2 2 0 002 2h2a2 2 0 002-2M5 9h14l-1 8H6L5 9z" />
<path d="M9 9V5a3 3 0 016 0v4" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
/**
* ErrorBoundary - React 渲染异常兜底fallback UI
*
* 用途:捕获子组件树渲染异常,展示降级 UI避免整页白屏。
*
* @example
* <ErrorBoundary fallback={<ErrorFallback />}>
* <Dashboard />
* </ErrorBoundary>
*/
export interface ErrorBoundaryProps {
children: ReactNode;
/** 自定义降级 UI未提供时使用默认 fallback */
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
/** 错误回调(上报 / 日志) */
onError?: (error: Error, info: ErrorInfo) => void;
/** 重置回调(用于外部触发重试) */
onReset?: () => void;
}
export interface ErrorBoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
this.props.onError?.(error, info);
}
reset = (): void => {
this.props.onReset?.();
this.setState({ error: null });
};
render(): ReactNode {
const { error } = this.state;
const { children, fallback } = this.props;
if (error) {
if (typeof fallback === "function") {
return fallback(error, this.reset);
}
if (fallback !== undefined) {
return fallback;
}
return <DefaultErrorFallback error={error} onReset={this.reset} />;
}
return children;
}
}
function DefaultErrorFallback({
error,
onReset,
}: {
error: Error;
onReset: () => void;
}): ReactNode {
return (
<div
role="alert"
className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-8"
>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<button
type="button"
onClick={onReset}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,38 @@
/**
* @edu/ui-components - 共享组件库
*
* 维护者ai13teacher-portal
* 关联文档teacher-portal 02-architecture-design.md §7
*
* 包含:
* - ErrorBoundaryReact 渲染异常兜底
* - Loading骨架屏 / 加载占位
* - Empty空态展示
* - RequirePermissionL3 组件级视口控制
* - cn类名合并工具project_rules §3.9
*
* 后续扩展P2+
* - AppShellMF Shell 布局)
* - shadcn/ui 基础组件Button/Input/Select/Modal/Toast/DataTable
* - A11y 工具集useA11yId/mergeA11yProps/describeInput/focus-trap
* - Formreact-hook-form + zodResolver
* - RichTextEditorTiptap 封装)
* - Chartrecharts 封装)
*/
export { ErrorBoundary } from "./error-boundary.js";
export type {
ErrorBoundaryProps,
ErrorBoundaryState,
} from "./error-boundary.js";
export { Loading } from "./loading.js";
export type { LoadingProps } from "./loading.js";
export { Empty } from "./empty.js";
export type { EmptyProps } from "./empty.js";
export { RequirePermission } from "./require-permission.js";
export type { RequirePermissionProps } from "./require-permission.js";
export { cn } from "./utils/cn.js";

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
/**
* Loading - 骨架屏 / 加载占位
*
* 用途:数据加载期间展示骨架屏,避免内容跳动。
*
* @example
* <Loading /> // 默认 3 行骨架
* <Loading lines={5} /> // 5 行骨架
* <Loading variant="spinner" /> // 旋转图标
*/
export interface LoadingProps {
/** 骨架行数variant="skeleton" 时生效) */
lines?: number;
/** 展示形态 */
variant?: "skeleton" | "spinner";
/** 自定义类名 */
className?: string;
/** aria-label无障碍 */
ariaLabel?: string;
}
export function Loading({
lines = 3,
variant = "skeleton",
className,
ariaLabel = "加载中",
}: LoadingProps): ReactNode {
if (variant === "spinner") {
return (
<div
role="status"
aria-label={ariaLabel}
className={cn("flex items-center justify-center p-8", className)}
>
<span
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary"
aria-hidden="true"
/>
<span className="sr-only">{ariaLabel}</span>
</div>
);
}
return (
<div
role="status"
aria-label={ariaLabel}
className={cn("flex flex-col gap-2 p-4", className)}
>
{Array.from({ length: lines }, (_, i) => (
<div
key={i}
className="h-4 animate-pulse rounded bg-muted"
style={{ width: `${100 - i * 10}%` }}
aria-hidden="true"
/>
))}
<span className="sr-only">{ariaLabel}</span>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
/**
* RequirePermission - L3 组件级视口控制
*
* 用途:根据当前用户权限点决定是否渲染 children。
* 无权限时不渲染 children返回 null 或自定义 fallback
*
* 权限点命名遵循 F7 裁决:`<RESOURCE>_<ACTION>`(如 `EXAM_READ`、`GRADE_READ_CHILD`)。
* 数据范围用后缀 `_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`)。
*
* 注意:本组件依赖 `@edu/hooks` 的 `usePermission()`。为避免循环依赖,
* 权限检查函数通过 props 注入由调用方apps/*)从 usePermission() 获取后传入。
*
* @example
* import { usePermission } from "@edu/hooks";
* const { hasPermission } = usePermission();
*
* <RequirePermission perm="EXAM_CREATE" hasPermission={hasPermission}>
* <Button>新建考试</Button>
* </RequirePermission>
*/
export interface RequirePermissionProps {
/** 权限点F7 规范:<RESOURCE>_<ACTION> */
perm: string;
/**
* 权限检查函数(由 @edu/hooks 的 usePermission().hasPermission 注入)
* 返回 true 渲染 childrenfalse 渲染 fallback。
*/
hasPermission: (perm: string) => boolean;
/** 子节点(有权限时渲染) */
children: ReactNode;
/** 无权限时的降级 UI未提供时返回 null */
fallback?: ReactNode;
}
export function RequirePermission({
perm,
hasPermission,
children,
fallback = null,
}: RequirePermissionProps): ReactNode {
if (!hasPermission(perm)) {
return fallback;
}
return children;
}

View File

@@ -0,0 +1,83 @@
/**
* 类名合并工具project_rules §3.9 强制使用)
*
* 用于管理条件类名,禁止字符串拼接动态类名(如 `bg-${color}-500`)。
* 基于 clsx + tailwind-merge 的轻量实现,后续接入 shadcn/ui 时替换为官方 cn()。
*/
type ClassValue =
| string
| number
| null
| false
| undefined
| ClassValue[]
| { [key: string]: unknown };
/**
* 合并类名,过滤 falsy 值。
*
* @example
* cn("px-2 py-1", isActive && "bg-primary", { "text-muted": isDisabled })
*/
export function cn(...inputs: ClassValue[]): string {
const classes: string[] = [];
for (const input of inputs) {
if (!input) continue;
if (typeof input === "string" || typeof input === "number") {
classes.push(String(input));
continue;
}
if (Array.isArray(input)) {
const nested = cn(...input);
if (nested) classes.push(nested);
continue;
}
if (typeof input === "object") {
for (const [key, value] of Object.entries(input)) {
if (value) classes.push(key);
}
}
}
// 去重 + 合并 Tailwind 冲突类(基础实现,后续替换为 tailwind-merge
return dedupeTailwindClasses(classes.join(" "));
}
/**
* 基础 Tailwind 类去重(同一前缀后者覆盖前者)。
* 完整实现待引入 tailwind-merge。
*/
function dedupeTailwindClasses(className: string): string {
const seen = new Set<string>();
const tokens = className.split(/\s+/).filter(Boolean);
// 反向遍历,保留后出现的同类令牌
for (let i = tokens.length - 1; i >= 0; i--) {
const token = tokens[i];
if (!token) continue;
const prefix = getTailwindPrefix(token);
const key = prefix ? `__${prefix}` : token;
if (seen.has(key)) continue;
seen.add(key);
}
// 恢复原始顺序
return tokens
.filter((t) => t && (seen.has(t) || seen.has(`__${getTailwindPrefix(t)}`)))
.join(" ");
}
function getTailwindPrefix(token: string): string | null {
// 匹配 Tailwind 前缀bg- text- p- m- w- h- border- 等
const match = token.match(
/^(bg|text|p|m|px|py|mx|my|w|h|min-h|min-w|border|rounded|shadow|font|leading|tracking|gap|space|flex|grid|col|row|inset|top|right|bottom|left|z|opacity|transition|duration|delay|animate|hover|focus|sm|md|lg|xl|2xl)-/,
);
return match ? match[1] : null;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,8 @@
{
"name": "@edu/ui-tokens",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}

View File

@@ -0,0 +1,39 @@
export type ColorScheme = {
background: string;
foreground: string;
primary: string;
secondary: string;
muted: string;
accent: string;
destructive: string;
border: string;
input: string;
ring: string;
};
export const colors = {
light: {
background: "210 40% 98%",
foreground: "222 47% 11%",
primary: "221 83% 53%",
secondary: "210 40% 96%",
muted: "210 40% 96%",
accent: "210 40% 94%",
destructive: "0 84% 60%",
border: "214 32% 91%",
input: "214 32% 91%",
ring: "221 83% 53%",
},
dark: {
background: "222 47% 11%",
foreground: "210 40% 98%",
primary: "217 91% 60%",
secondary: "217 33% 17%",
muted: "217 33% 17%",
accent: "217 33% 20%",
destructive: "0 63% 31%",
border: "217 33% 20%",
input: "217 33% 20%",
ring: "217 91% 60%",
},
} as const satisfies Record<"light" | "dark", ColorScheme>;

View File

@@ -0,0 +1,4 @@
export * from "./colors.js";
export * from "./typography.js";
export * from "./spacing.js";
export * from "./shadows.js";

View File

@@ -0,0 +1,6 @@
export const shadows = {
sm: "0 1px 2px 0 hsl(220 23% 15% / 0.05)",
md: "0 4px 6px -1px hsl(220 23% 15% / 0.1), 0 2px 4px -2px hsl(220 23% 15% / 0.1)",
lg: "0 10px 15px -3px hsl(220 23% 15% / 0.1), 0 4px 6px -4px hsl(220 23% 15% / 0.1)",
xl: "0 20px 25px -5px hsl(220 23% 15% / 0.1), 0 8px 10px -6px hsl(220 23% 15% / 0.1)",
} as const;

View File

@@ -0,0 +1,23 @@
export const spacing = {
space0: "0rem",
space1: "0.25rem",
space2: "0.5rem",
space3: "0.75rem",
space4: "1rem",
space5: "1.25rem",
space6: "1.5rem",
space7: "1.75rem",
space8: "2rem",
space9: "2.25rem",
space10: "2.5rem",
space11: "3rem",
space12: "3.5rem",
space13: "4rem",
space14: "4.5rem",
space15: "5rem",
space16: "6rem",
space17: "7rem",
space18: "8rem",
space19: "9rem",
space20: "10rem",
} as const;

View File

@@ -0,0 +1,17 @@
export const fontSizes = {
fontSize1: "0.75rem",
fontSize2: "0.875rem",
fontSize3: "1rem",
fontSize4: "1.125rem",
fontSize5: "1.25rem",
fontSize6: "1.5rem",
fontSize7: "1.875rem",
fontSize8: "2.25rem",
fontSize9: "3rem",
} as const;
export const fontFamilies = {
sans: "var(--font-family-sans)",
serif: "var(--font-family-serif)",
mono: "var(--font-family-mono)",
} as const;

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}