fix: 修复集成测试中发现的全部 bug — 15 服务端到端验证通过

修复涵盖 6 大类问题:

1. api-gateway
   - 路径前缀剥离 /api 而非 /api/v1,保留下游 /v1/ controller 前缀
   - JWKS URL 默认值修复
   - publicPaths 白名单对齐 /v1/iam/*

2. iam
   - iam.module.ts exports 补充 PermissionCacheService 和 IamRepository
   - main.ts resolveProtoPath() 多路径探测 proto 文件

3. core-edu
   - app.module.ts AuthMiddleware 全局注册

4. BFF 层 GraphQL 端点(teacher-bff / parent-bff / student-bff)
   - teacher-bff: mock dataScope OWN→SELF 对齐 GraphQL enum;WHATWG Request header .get() 提取
   - parent-bff: handleNodeRequestAndResponse 不存在 → 直接 yoga(req,res);WHATWG Request header .get() 提取
   - student-bff: auth.resolver 移除 ActionState 信封返回扁平对象;WHATWG Request header .get() 提取

5. ai
   - Kafka 事务降级 + 10s 超时
   - gRPC 拦截器降级
   - dev mode 禁用事务模式

6. 前端 + 共享包
   - teacher-portal: MF 插件条件实例化 + transpilePackages + extensionAlias
   - ui-components: error-boundary.tsx 添加 use client
   - ui-tokens: tailwind-theme.css 移除 @layer base
   - shared-ts: 导出从源码改为 dist 编译产物;OutboxModule global:true

7. infra
   - .gitignore 补充 keys/ *.pem *.key secrets/ 排除规则
   - infra/init-sql/02-all-services-schema.sql 36 张表 DDL

验证结果:
- TS typecheck: 19 个 workspace 项目全部通过
- Go vet + Ruff: 通过
- 15 服务全部启动成功
- 3 个 BFF GraphQL 端点 + 4 个前端页面全部 200
- Gateway → iam → core-edu 端到端链路验证通过

AI identity: trae-main(集成测试修复会话)
This commit is contained in:
SpecialX
2026-07-11 01:41:46 +08:00
parent c6362f4b04
commit 61d824924a
35 changed files with 1020 additions and 192 deletions

6
.gitignore vendored
View File

@@ -34,6 +34,12 @@ venv/
.env.local
.env.*.local
# Secrets / Keys (JWT RS256 等)
keys/
*.pem
*.key
secrets/
# IDE
.vscode/
.idea/

View File

@@ -1,2 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -78,9 +78,31 @@ function buildShared() {
const nextConfig = {
reactStrictMode: true,
// ESM 模式transpilePackages 对齐 NestJS ESM 规范
// @edu/hooks 等包使用 .js 后缀导入源码,需 transpile + extensionAlias
transpilePackages: [
"@edu/ui-components",
"@edu/ui-tokens",
"@edu/hooks",
"@edu/contracts",
"@edu/shared-ts",
],
// ARB-002MF Shell 配置exposes + sharedP2 无 remotes
// 仅当 NEXT_PUBLIC_MF_ENABLED=true 时实例化 NextFederationPlugin
// nextjs-mf 不支持 App DirectoryP2 阶段 MF 未启用时跳过避免报错)
webpack(config, { isServer }) {
if (NextFederationPlugin) {
// ESM 包使用 .js 后缀导入源码TS 文件),需映射 .js → .ts
config.resolve = config.resolve || {};
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
".js": [".ts", ".tsx", ".js"],
};
if (
NextFederationPlugin &&
process.env.NEXT_PUBLIC_MF_ENABLED === "true"
) {
config.plugins.push(
new NextFederationPlugin({
name: "teacher_app",

View File

@@ -11,19 +11,20 @@
### 1.1 多语言 monorepo 配置
| 场景 | 技术/规则 |
| ------------------------ | ------------------------------------------------------------------------------------------------------------- |
| 多语言 workspace | pnpm workspaceTS+ go.workGo+ pyproject.toml/uv workspacePython三套并存 |
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
| pnpm-workspace.yaml | 仅声明 TS 包路径packages/_、services/classes、bff/_、apps/_、scripts/_Go/Python 不入 |
| go.work | 列出所有 Go 服务模块services/api-gateway、services/push-gateway |
| pyproject.toml | uv workspace members 列 Python 服务services/data-ana、services/ai-gateway |
| 跨语言共享类型 | protobuf 生成三端代码TS/Go/Python单一契约源 |
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
| husky + commitlint | pre-commit 跑 eslint+prettiercommit-msg 校验 Conventional Commits |
| .editorconfig 多语言缩进 | Go 用 tabPython 用 4 空格TS/默认用 2 空格 |
| ESLint 9 flat config | P6 硬化:创建 `eslint.config.js`flat configlint 脚本去掉 `--ext .ts`lint-staged 恢复 `eslint --fix` |
| next lint 交互式初始化 | teacher-portal 无 `.eslintrc.json``next lint` 触发 Strict/Base 选择提示CI 中需预置配置或改 `eslint src` |
| 场景 | 技术/规则 |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 多语言 workspace | pnpm workspaceTS+ go.workGo+ pyproject.toml/uv workspacePython三套并存 |
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
| pnpm-workspace.yaml | 仅声明 TS 包路径packages/_、services/classes、bff/_、apps/_、scripts/_Go/Python 不入 |
| go.work | 列出所有 Go 服务模块services/api-gateway、services/push-gateway |
| pyproject.toml | uv workspace members 列 Python 服务services/data-ana、services/ai-gateway |
| 跨语言共享类型 | protobuf 生成三端代码TS/Go/Python单一契约源 |
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
| husky + commitlint | pre-commit 跑 eslint+prettiercommit-msg 校验 Conventional Commits |
| .editorconfig 多语言缩进 | Go 用 tabPython 用 4 空格TS/默认用 2 空格 |
| ESLint 9 flat config | P6 硬化:创建 `eslint.config.js`flat configlint 脚本去掉 `--ext .ts`lint-staged 恢复 `eslint --fix` |
| next lint 交互式初始化 | teacher-portal 无 `.eslintrc.json``next lint` 触发 Strict/Base 选择提示CI 中需预置配置或改 `eslint src` |
| uv.lock 解析失败 | `uv.lock``grpcio-health-checking``source` 字段且匹配多包,导致 `uv run pytest` 全 workspace Python 服务不可用,需重建 lock`uv lock` |
### 1.2 Docker Compose 基础设施
@@ -244,30 +245,32 @@
### 2.3 iamTS/NestJS
| 场景 | 技术/规则 |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 双入口 | REST `/v1/iam/*` + gRPC 50052package `next_edu_cloud.iam.v1`),同一 IamService 实例共用Hybrid app `connectMicroservice` |
| 认证 | RS256 非对称签名,私钥本地文件(`IAM_PRIVATE_KEY_PATH`),公钥通过 JWKS 端点暴露给 Gateway |
| JWT payload | `{ sub, email, roles, dataScope, type, iss, aud, jti(仅 refresh), kid(header) }`register 自动分配 teacher 角色 |
| Token TTL | access 15min / refresh 7dayrefresh 含 jti轮换时旧 jti 加入 Redis 黑名单 |
| RBAC | 角色/权限/角色-权限查询 + `getEffectivePermissions(userId)` API三层角色模型system/organization/temporarylevel 0/1/2 |
| 视口配置 | 4 层模型(导航/路由/组件/数据),`iam_role_viewports` 表含 `level` ENUM(admin/teacher/student/parent) |
| DataScope 解析 | 6 级self/subject/class/grade/school/allSUBJECT 替代 DISTRICT注入 JWT payload |
| 权限校验 | PermissionGuardAPP_GUARDDB 驱动 + Redis 缓存 TTL 5min`data_scope=all` 直放行Key `iam:perm:{userId}` |
| Token 黑名单 | Redis Key `iam:bl:{jti}` TTL 与 refresh_token 剩余有效期对齐logout/refresh 时写入 |
| 审计日志 | `iam_user_audit_log` 表 + `AuditCreated` Kafka 事件,登录/角色变更/密码修改等关键操作记录 |
| 家长-学生关系 | `iam_student_guardians` 表unique(studentId, guardianId)`GetChildrenByParent` RPC + `GET /v1/iam/children` REST |
| schema 表 | iam_users / iam_roles / iam_user_roles / iam_permissions / iam_role_permissions / iam_refresh_tokens / iam_role_viewports / iam_student_guardians / iam_user_audit_log / iam_password_history |
| Outbox | shared-ts `OutboxModule.forRoot({ config, db, kafkaProducer })`,表名 `iam_outbox`topic `edu.iam.user.events`/`edu.iam.role.events`/`edu.iam.audit.created` |
| AuthMiddleware 注册 | `app.module.ts` `configure()` 注册于 me/logout/viewports/permissions/roles/audit/children 路由,解析 `x-user-*` 头 |
| 公开路径 | register/login/refresh/jwks.json 无 `@RequirePermission()`PermissionGuard 旁路 |
| ESM 模式 DI | `providers: [IamService, IamRepository]` + 构造器 `@Inject(IamRepository)` 显式注入,避免 `undefined` 运行时错误 |
| Drizzle ORM API | `inArray(col, vals)` 替代不存在的 `.in()``delete().where()` 不支持链式多次 where用 `and(eq(...), eq(...))` 组合 |
| Drizzle DataScope 类型 | `mysqlEnum` 推断类型为字面量联合Repository 方法参数须用 `DataScope` 类型而非 `string`,否则 TS 报错 |
| ioredis 类型 | `import { Redis } from "ioredis"`named import`type RedisClient = InstanceType<typeof Redis>`,默认导入在 TS 中不可构造 |
| jwt.sign expiresIn 类型 | `@types/jsonwebtoken` v9 `expiresIn` 类型 `number \| StringValue`,普通 string 不兼容,用 `ttlToSeconds(ttl)` 返回 number |
| 健康检查依赖 | `/readyz` 5 依赖DB(SELECT 1) / Redis(ping) / Kafka(producer 实例) / JWKS(密钥文件可读) / gRPC(进程内) |
| 优雅停机 | 顺序Kafka producer disconnect → Redis quit → DB pool endLifecycleService `onApplicationShutdown` |
| 场景 | 技术/规则 |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 双入口 | REST `/v1/iam/*` + gRPC 50052package `next_edu_cloud.iam.v1`),同一 IamService 实例共用Hybrid app `connectMicroservice` |
| 认证 | RS256 非对称签名,私钥本地文件(`IAM_PRIVATE_KEY_PATH`),公钥通过 JWKS 端点暴露给 Gateway |
| JWT payload | `{ sub, email, roles, dataScope, type, iss, aud, jti(仅 refresh), kid(header) }`register 自动分配 teacher 角色 |
| Token TTL | access 15min / refresh 7dayrefresh 含 jti轮换时旧 jti 加入 Redis 黑名单 |
| RBAC | 角色/权限/角色-权限查询 + `getEffectivePermissions(userId)` API三层角色模型system/organization/temporarylevel 0/1/2 |
| 视口配置 | 4 层模型(导航/路由/组件/数据),`iam_role_viewports` 表含 `level` ENUM(admin/teacher/student/parent) |
| DataScope 解析 | 6 级self/subject/class/grade/school/allSUBJECT 替代 DISTRICT注入 JWT payload |
| 权限校验 | PermissionGuardAPP_GUARDDB 驱动 + Redis 缓存 TTL 5min`data_scope=all` 直放行Key `iam:perm:{userId}` |
| Token 黑名单 | Redis Key `iam:bl:{jti}` TTL 与 refresh_token 剩余有效期对齐logout/refresh 时写入 |
| 审计日志 | `iam_user_audit_log` 表 + `AuditCreated` Kafka 事件,登录/角色变更/密码修改等关键操作记录 |
| 家长-学生关系 | `iam_student_guardians` 表unique(studentId, guardianId)`GetChildrenByParent` RPC + `GET /v1/iam/children` REST |
| schema 表 | iam_users / iam_roles / iam_user_roles / iam_permissions / iam_role_permissions / iam_refresh_tokens / iam_role_viewports / iam_student_guardians / iam_user_audit_log / iam_password_history |
| Outbox | shared-ts `OutboxModule.forRoot({ config, db, kafkaProducer })`,表名 `iam_outbox`topic `edu.iam.user.events`/`edu.iam.role.events`/`edu.iam.audit.created` |
| AuthMiddleware 注册 | `app.module.ts` `configure()` 注册于 me/logout/viewports/permissions/roles/audit/children 路由,解析 `x-user-*` 头 |
| 公开路径 | register/login/refresh/jwks.json 无 `@RequirePermission()`PermissionGuard 旁路 |
| ESM 模式 DI | `providers: [IamService, IamRepository]` + 构造器 `@Inject(IamRepository)` 显式注入,避免 `undefined` 运行时错误 |
| APP_GUARD DI 导出 | PermissionGuard 注册为 APP_GUARDAppModule 级),其依赖 PermissionCacheService/IamRepository 须在 IamModule `exports` 中导出,否则 AppModule 上下文无法注入 |
| Proto 路径解析 | `resolveProtoPath()` 兼顾 cwd=monorepo 根CI与 cwd=services/iam`pnpm --filter` dev先 `packages/shared-proto/proto/`,回退 `../../packages/shared-proto/proto/` 与本地 `proto/`Docker |
| Drizzle ORM API | `inArray(col, vals)` 替代不存在的 `.in()``delete().where()` 不支持链式多次 where用 `and(eq(...), eq(...))` 组合 |
| Drizzle DataScope 类型 | `mysqlEnum` 推断类型为字面量联合Repository 方法参数须用 `DataScope` 类型而非 `string`,否则 TS 报错 |
| ioredis 类型 | `import { Redis } from "ioredis"`named import`type RedisClient = InstanceType<typeof Redis>`,默认导入在 TS 中不可构造 |
| jwt.sign expiresIn 类型 | `@types/jsonwebtoken` v9 `expiresIn` 类型为 `number \| StringValue`,普通 string 不兼容,用 `ttlToSeconds(ttl)` 返回 number |
| 健康检查依赖 | `/readyz` 5 依赖DB(SELECT 1) / Redis(ping) / Kafka(producer 实例) / JWKS(密钥文件可读) / gRPC(进程内) |
| 优雅停机 | 顺序Kafka producer disconnect → Redis quit → DB pool endLifecycleService `onApplicationShutdown` |
### 2.4 core-eduTS/NestJSP3

View File

@@ -0,0 +1,609 @@
-- ============================================================
-- Edu 微服务 All-Services Schema (P2 阶段,单库 next_edu_cloud)
-- 由 Drizzle ORM schema 文件转换而来
-- 使用 CREATE TABLE IF NOT EXISTS可重复执行
-- 字符集utf8mb4 / utf8mb4_unicode_ci引擎InnoDB
-- ============================================================
-- ============================================================
-- 1. IAM 服务services/iam/src/iam/iam.schema.ts
-- ============================================================
-- 1.1 用户表
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','subject','class','grade','school','all') NOT NULL DEFAULT 'self',
`password_changed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`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 `uniq_iam_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.2 角色表
CREATE TABLE IF NOT EXISTS `iam_roles` (
`id` CHAR(36) NOT NULL,
`name` VARCHAR(50) NOT NULL,
`description` VARCHAR(255) NULL,
`role_type` ENUM('system','organization','temporary') NOT NULL DEFAULT 'system',
`level` INT NOT NULL DEFAULT 0,
`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 `uniq_iam_roles_name` (`name`),
INDEX `idx_iam_roles_type` (`role_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.3 用户-角色关联表
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,
INDEX `idx_iam_user_roles_pk` (`user_id`, `role_id`),
INDEX `idx_iam_user_roles_role` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.4 权限表
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 `uniq_iam_permissions_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.5 角色-权限关联表
CREATE TABLE IF NOT EXISTS `iam_role_permissions` (
`role_id` CHAR(36) NOT NULL,
`permission_id` CHAR(36) NOT NULL,
INDEX `idx_iam_role_permissions_pk` (`role_id`, `permission_id`),
INDEX `idx_iam_role_permissions_perm` (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.6 刷新令牌表
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,
`jti` VARCHAR(36) 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` (`user_id`),
INDEX `idx_iam_refresh_tokens_jti` (`jti`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.7 视口配置表4 层模型)
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,
`level` ENUM('admin','teacher','student','parent') NOT NULL DEFAULT 'teacher',
`component_config` TEXT NULL,
PRIMARY KEY (`id`),
INDEX `idx_iam_role_viewports_role` (`role_id`),
INDEX `idx_iam_role_viewports_level` (`level`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.8 学生-家长关系表
CREATE TABLE IF NOT EXISTS `iam_student_guardians` (
`id` CHAR(36) NOT NULL,
`student_id` CHAR(36) NOT NULL,
`guardian_id` CHAR(36) NOT NULL,
`relation` VARCHAR(20) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_student_guardian` (`student_id`, `guardian_id`),
INDEX `idx_iam_student_guardians_guardian` (`guardian_id`),
INDEX `idx_iam_student_guardians_student` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.9 审计日志表
CREATE TABLE IF NOT EXISTS `iam_user_audit_log` (
`id` CHAR(36) NOT NULL,
`actor_user_id` CHAR(36) NOT NULL,
`action` VARCHAR(50) NOT NULL,
`resource_type` VARCHAR(50) NOT NULL,
`resource_id` VARCHAR(36) NOT NULL,
`before_state` TEXT NULL,
`after_state` TEXT NULL,
`ip` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
`trace_id` VARCHAR(64) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_iam_audit_actor` (`actor_user_id`),
INDEX `idx_iam_audit_resource` (`resource_type`, `resource_id`),
INDEX `idx_iam_audit_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.10 密码历史表
CREATE TABLE IF NOT EXISTS `iam_password_history` (
`id` CHAR(36) NOT NULL,
`user_id` CHAR(36) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_iam_password_history_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 2. Classes 服务services/classes/src/classes/classes.schema.ts
-- 班级表(已在 01-init.sql 中创建此处保留以保完整性IF NOT EXISTS 安全)
-- ============================================================
CREATE TABLE IF NOT EXISTS `classes` (
`id` CHAR(36) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`grade_id` CHAR(36) NOT NULL,
`head_teacher_id` CHAR(36) NULL,
`description` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 3. Core-EDU 服务services/core-edu/src/**/*.schema.ts
-- ============================================================
-- 3.1 教师关联表IAM 事件消费幂等)
CREATE TABLE IF NOT EXISTS `core_edu_teacher_associations` (
`id` CHAR(36) NOT NULL,
`teacher_id` CHAR(36) NOT NULL,
`class_id` CHAR(36) NOT NULL,
`subject_id` CHAR(36) NULL,
`school_id` CHAR(36) NOT NULL,
`source` VARCHAR(20) NOT NULL DEFAULT 'iam_event',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_teacher_class_subject` (`teacher_id`, `class_id`, `subject_id`),
INDEX `idx_teacher_assoc_teacher_id` (`teacher_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.2 考试主表
CREATE TABLE IF NOT EXISTS `core_edu_exams` (
`id` CHAR(36) NOT NULL,
`class_id` CHAR(36) NOT NULL,
`subject_id` CHAR(36) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`description` TEXT NULL,
`exam_date` DATETIME NOT NULL,
`duration` INT NOT NULL,
`total_score` DECIMAL(6,2) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`status_changed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`status_changed_by` CHAR(36) NULL,
`school_id` CHAR(36) NOT NULL,
`created_by` CHAR(36) NOT NULL,
`archived_at` DATETIME NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_exams_class_status` (`class_id`, `status`),
INDEX `idx_exams_school_date` (`school_id`, `exam_date`),
INDEX `idx_exams_created_by` (`created_by`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.3 考试题目关联表
CREATE TABLE IF NOT EXISTS `core_edu_exam_questions` (
`id` CHAR(36) NOT NULL,
`exam_id` CHAR(36) NOT NULL,
`question_id` CHAR(36) NOT NULL,
`order` INT NOT NULL,
`score` DECIMAL(6,2) NOT NULL,
`question_type` VARCHAR(30) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_exam_questions_exam_id` (`exam_id`),
INDEX `idx_exam_questions_question_id` (`question_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.4 考试提交表
CREATE TABLE IF NOT EXISTS `core_edu_exam_submissions` (
`id` CHAR(36) NOT NULL,
`exam_id` CHAR(36) NOT NULL,
`student_id` CHAR(36) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'not_submitted',
`submitted_at` DATETIME NULL,
`graded_at` DATETIME NULL,
`graded_by` CHAR(36) NULL,
`total_score` DECIMAL(6,2) NULL,
`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 `uniq_exam_student` (`exam_id`, `student_id`),
INDEX `idx_exam_submissions_exam_id` (`exam_id`),
INDEX `idx_exam_submissions_student_id` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.5 作业主表
CREATE TABLE IF NOT EXISTS `core_edu_homework` (
`id` CHAR(36) NOT NULL,
`class_id` CHAR(36) NOT NULL,
`subject_id` CHAR(36) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`description` TEXT NULL,
`due_date` DATETIME NOT NULL,
`grace_period` INT NOT NULL DEFAULT 300,
`status` VARCHAR(20) NOT NULL DEFAULT 'assigned',
`school_id` CHAR(36) NOT NULL,
`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,
PRIMARY KEY (`id`),
INDEX `idx_homework_class_status` (`class_id`, `status`),
INDEX `idx_homework_created_by` (`created_by`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.6 作业提交表
CREATE TABLE IF NOT EXISTS `core_edu_homework_submissions` (
`id` CHAR(36) NOT NULL,
`homework_id` CHAR(36) NOT NULL,
`student_id` CHAR(36) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'not_submitted',
`submitted_at` DATETIME NULL,
`graded_at` DATETIME NULL,
`graded_by` CHAR(36) NULL,
`total_score` DECIMAL(6,2) NULL,
`feedback` TEXT NULL,
`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 `uniq_hw_student` (`homework_id`, `student_id`),
INDEX `idx_hw_submissions_homework_id` (`homework_id`),
INDEX `idx_hw_submissions_student_id` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.7 作业答题表
CREATE TABLE IF NOT EXISTS `core_edu_homework_answers` (
`id` CHAR(36) NOT NULL,
`submission_id` CHAR(36) NOT NULL,
`question_id` CHAR(36) NOT NULL,
`answer` TEXT NULL,
`score` DECIMAL(6,2) NULL,
`teacher_comment` TEXT NULL,
`is_correct` TINYINT(1) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_hw_answers_submission_id` (`submission_id`),
INDEX `idx_hw_answers_question_id` (`question_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.8 成绩主表
CREATE TABLE IF NOT EXISTS `core_edu_grades` (
`id` CHAR(36) NOT NULL,
`student_id` CHAR(36) NOT NULL,
`exam_id` CHAR(36) NULL,
`homework_id` CHAR(36) NULL,
`score` DECIMAL(6,2) NOT NULL,
`total_score` DECIMAL(6,2) NOT NULL,
`feedback` TEXT NULL,
`graded_by` CHAR(36) NOT NULL,
`school_id` CHAR(36) NOT NULL,
`idempotency_key` VARCHAR(128) NULL,
`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 `uniq_student_exam` (`student_id`, `exam_id`),
UNIQUE KEY `uniq_student_hw` (`student_id`, `homework_id`),
UNIQUE KEY `uniq_idempotency` (`idempotency_key`),
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 COLLATE=utf8mb4_unicode_ci;
-- 3.9 成绩公式表
CREATE TABLE IF NOT EXISTS `core_edu_grade_formulas` (
`id` CHAR(36) NOT NULL,
`scope` VARCHAR(20) NOT NULL,
`scope_id` CHAR(36) NOT NULL,
`formula_type` VARCHAR(20) NOT NULL,
`weights` JSON NULL,
`custom_expression` TEXT NULL,
`effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_grade_formulas_scope` (`scope`, `scope_id`),
INDEX `idx_grade_formulas_effective` (`effective_from`, `effective_to`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.10 考勤记录表
CREATE TABLE IF NOT EXISTS `core_edu_attendance` (
`id` CHAR(36) NOT NULL,
`schedule_id` CHAR(36) NOT NULL,
`student_id` CHAR(36) NOT NULL,
`status` VARCHAR(20) NOT NULL,
`remark` TEXT NULL,
`recorded_by` CHAR(36) NOT NULL,
`school_id` CHAR(36) NOT NULL,
`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 `uniq_sched_student` (`schedule_id`, `student_id`),
INDEX `idx_attendance_student_id` (`student_id`),
INDEX `idx_attendance_schedule_id` (`schedule_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.11 课程表
CREATE TABLE IF NOT EXISTS `core_edu_courses` (
`id` CHAR(36) NOT NULL,
`class_id` CHAR(36) NOT NULL,
`subject_id` CHAR(36) NOT NULL,
`teacher_id` CHAR(36) NOT NULL,
`school_id` CHAR(36) NOT NULL,
`name` VARCHAR(200) NOT NULL,
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_courses_class_id` (`class_id`),
INDEX `idx_courses_teacher_id` (`teacher_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.12 课次表
CREATE TABLE IF NOT EXISTS `core_edu_lessons` (
`id` CHAR(36) NOT NULL,
`course_id` CHAR(36) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`order` INT NOT NULL,
`knowledge_point_ids` JSON NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_lessons_course_id` (`course_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.13 排课表
CREATE TABLE IF NOT EXISTS `core_edu_schedules` (
`id` CHAR(36) NOT NULL,
`course_id` CHAR(36) NOT NULL,
`lesson_id` CHAR(36) NULL,
`teacher_id` CHAR(36) NOT NULL,
`class_id` CHAR(36) NOT NULL,
`room_id` CHAR(36) NULL,
`start_time` DATETIME NOT NULL,
`end_time` DATETIME NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'scheduled',
`school_id` CHAR(36) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_schedules_teacher_time` (`teacher_id`, `start_time`, `end_time`),
INDEX `idx_schedules_class_time` (`class_id`, `start_time`, `end_time`),
INDEX `idx_schedules_course_id` (`course_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3.14 Core-EDU Outbox 表(事务性发件箱)
CREATE TABLE IF NOT EXISTS `core_edu_outbox` (
`id` CHAR(36) NOT NULL,
`event_id` CHAR(36) NOT NULL,
`aggregate_id` CHAR(36) NOT NULL,
`aggregate_type` VARCHAR(50) NOT NULL,
`event_type` VARCHAR(100) NOT NULL,
`occurred_at` DATETIME NOT NULL,
`payload` TEXT NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`retry_count` BIGINT NOT NULL DEFAULT 0,
`next_retry_at` DATETIME NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processed_at` TIMESTAMP NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_event_id` (`event_id`),
INDEX `idx_outbox_status_retry` (`status`, `next_retry_at`),
INDEX `idx_outbox_aggregate` (`aggregate_type`, `aggregate_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 4. Content 服务services/content/src/**/*.schema.ts
-- ============================================================
-- 4.1 教材表
CREATE TABLE IF NOT EXISTS `content_textbooks` (
`id` VARCHAR(32) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`subject_id` VARCHAR(32) NOT NULL,
`grade_id` VARCHAR(32) NOT NULL,
`version` VARCHAR(32) NOT NULL DEFAULT '1.0',
`status` VARCHAR(32) NOT NULL DEFAULT 'draft',
`tenant_id` VARCHAR(32) NULL,
`metadata` JSON NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_textbooks_subject_grade` (`subject_id`, `grade_id`),
INDEX `idx_textbooks_status` (`status`),
INDEX `idx_textbooks_tenant` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4.2 章节表
CREATE TABLE IF NOT EXISTS `content_chapters` (
`id` VARCHAR(32) NOT NULL,
`textbook_id` VARCHAR(32) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`order_num` INT NOT NULL DEFAULT 0,
`parent_id` VARCHAR(32) NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'draft',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_chapters_textbook_order` (`textbook_id`, `order_num`),
INDEX `idx_chapters_parent` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4.3 知识点表
CREATE TABLE IF NOT EXISTS `content_knowledge_points` (
`id` VARCHAR(32) NOT NULL,
`chapter_id` VARCHAR(32) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`difficulty` TINYINT NOT NULL DEFAULT 3,
`metadata` JSON NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_kp_chapter` (`chapter_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4.4 题目表
CREATE TABLE IF NOT EXISTS `content_questions` (
`id` VARCHAR(32) NOT NULL,
`knowledge_point_id` VARCHAR(32) NOT NULL,
`type` VARCHAR(32) NOT NULL,
`content` TEXT NOT NULL,
`options` JSON NULL,
`answer` TEXT NOT NULL,
`explanation` TEXT NULL,
`difficulty` TINYINT NOT NULL DEFAULT 3,
`status` VARCHAR(32) NOT NULL DEFAULT 'draft',
`source` VARCHAR(32) NOT NULL DEFAULT 'manual',
`created_by` VARCHAR(32) NOT NULL,
`metadata` JSON NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_questions_kp` (`knowledge_point_id`),
INDEX `idx_questions_type_difficulty` (`type`, `difficulty`),
INDEX `idx_questions_status` (`status`),
INDEX `idx_questions_source` (`source`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4.5 Content Outbox 表
CREATE TABLE IF NOT EXISTS `content_outbox_events` (
`id` VARCHAR(32) NOT NULL,
`aggregate_type` VARCHAR(64) NOT NULL,
`aggregate_id` VARCHAR(32) NOT NULL,
`event_type` VARCHAR(100) NOT NULL,
`topic` VARCHAR(128) NOT NULL,
`payload` TEXT NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`retry_count` INT NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` TIMESTAMP NULL,
`next_retry_at` TIMESTAMP NULL,
`last_error` TEXT NULL,
PRIMARY KEY (`id`),
INDEX `idx_outbox_status_retry` (`status`, `next_retry_at`),
INDEX `idx_outbox_aggregate` (`aggregate_type`, `aggregate_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 5. MSG 服务services/msg/src/**/*.schema.ts
-- ============================================================
-- 5.1 通知主表
CREATE TABLE IF NOT EXISTS `msg_notifications` (
`id` VARCHAR(32) NOT NULL,
`user_id` VARCHAR(32) NOT NULL,
`type` VARCHAR(32) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`content` TEXT NOT NULL,
`channel` VARCHAR(32) NOT NULL,
`is_read` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
`metadata` JSON NULL,
`related_entity_type` VARCHAR(64) NULL,
`related_entity_id` VARCHAR(32) NULL,
`group_id` VARCHAR(32) NULL,
`sender_id` VARCHAR(32) NULL,
`template_id` VARCHAR(32) NULL,
`event_id` VARCHAR(64) NULL,
`read_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5.2 用户偏好表
CREATE TABLE IF NOT EXISTS `msg_notification_preferences` (
`id` VARCHAR(32) NOT NULL,
`user_id` VARCHAR(32) NOT NULL,
`type` VARCHAR(32) NOT NULL,
`channels` JSON NOT NULL,
`frequency_limit` INT NULL,
`quiet_hours_start` VARCHAR(8) NULL,
`quiet_hours_end` VARCHAR(8) NULL,
`quiet_hours_timezone` VARCHAR(64) NULL DEFAULT 'Asia/Shanghai',
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5.3 通知模板表
CREATE TABLE IF NOT EXISTS `msg_notification_templates` (
`id` VARCHAR(32) NOT NULL,
`code` VARCHAR(64) NOT NULL,
`type` VARCHAR(32) NOT NULL,
`title_template` VARCHAR(255) NOT NULL,
`content_template` TEXT NOT NULL,
`default_channels` JSON NOT NULL,
`variables` JSON NOT NULL,
`locale` VARCHAR(16) NOT NULL DEFAULT 'zh-CN',
`status` VARCHAR(32) NOT NULL DEFAULT 'draft',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5.4 投递记录表
CREATE TABLE IF NOT EXISTS `msg_notification_deliveries` (
`id` VARCHAR(32) NOT NULL,
`notification_id` VARCHAR(32) NOT NULL,
`channel` VARCHAR(32) NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
`external_id` VARCHAR(128) NULL,
`attempt_count` INT NOT NULL DEFAULT 0,
`max_retry` INT NOT NULL DEFAULT 3,
`last_error` TEXT NULL,
`next_retry_at` TIMESTAMP NULL,
`delivered_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5.5 MSG Outbox 表
CREATE TABLE IF NOT EXISTS `msg_outbox_events` (
`event_id` VARCHAR(64) NOT NULL,
`aggregate_type` VARCHAR(64) NOT NULL,
`aggregate_id` VARCHAR(32) NOT NULL,
`event_type` VARCHAR(64) NOT NULL,
`topic` VARCHAR(128) NOT NULL,
`payload` JSON NOT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'pending',
`retry_count` INT NOT NULL DEFAULT 0,
`max_retry_count` INT NOT NULL DEFAULT 5,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` TIMESTAMP NULL,
`next_retry_at` TIMESTAMP NULL,
`last_error` TEXT NULL,
`metadata` JSON NULL,
PRIMARY KEY (`event_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5.6 已处理事件表(消费幂等)
CREATE TABLE IF NOT EXISTS `processed_events` (
`event_id` VARCHAR(64) NOT NULL,
`topic` VARCHAR(128) NOT NULL,
`processed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`event_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -452,6 +452,12 @@ type WeakPoint {
lastAssessedAt: DateTime
}
type LearningTrendSummary {
studentId: ID!
subjectId: ID
points: [TrendPoint!]!
}
type LearningTrend {
studentId: ID!
subjectId: ID
@@ -567,6 +573,12 @@ type AIChatPayload implements Degradable {
degradedFields: [String!]
}
input AIChatInput {
messages: [AIChatMessageInput!]!
model: String = "gpt-4o-mini"
context: AIChatContextInput
}
# ============================================================================
# Mutation 结果
# ============================================================================
@@ -723,6 +735,11 @@ type Query {
# @permission: NOTIFICATION_READ
# @dataScope: OWN
myNotificationUnreadCount: UnreadCountPayload!
"""AI 答疑 (同步, ai.Chat). 实际为写操作但 schema 设计为 Query 便于前端 GET 缓存"""
# @permission: STUDENT_AI_CHAT
# @dataScope: OWN
aiChat(input: AIChatInput!): AIChatPayload!
}
# ============================================================================

View File

@@ -5,15 +5,16 @@
"type": "module",
"exports": {
"./outbox": {
"types": "./src/outbox/index.ts",
"default": "./src/outbox/index.ts"
"types": "./dist/outbox/index.d.ts",
"default": "./dist/outbox/index.js"
},
"./bff": {
"types": "./src/bff/index.ts",
"default": "./src/bff/index.ts"
"types": "./dist/bff/index.d.ts",
"default": "./dist/bff/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},

View File

@@ -60,6 +60,7 @@ export class OutboxModule {
return {
module: OutboxModule,
global: true,
providers: [
{ provide: OUTBOX_CONFIG, useValue: options.config },
{ provide: OUTBOX_DB, useValue: options.db },

View File

@@ -1,3 +1,5 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
/**

View File

@@ -18,14 +18,12 @@
*/
/* 显式引用语义令牌,确保 CSS 变量在构建产物中可用 */
@layer base {
:root {
/* Tailwind 颜色类映射(与 tailwind.config.js theme.extend.colors 对齐) */
--tw-color-paper: var(--bg-paper);
--tw-color-surface: var(--bg-surface);
--tw-color-ink: var(--color-ink);
--tw-color-ink-muted: var(--color-ink-muted);
--tw-color-accent: var(--color-accent);
--tw-color-rule: var(--color-rule);
}
:root {
/* Tailwind 颜色类映射(与 tailwind.config.js theme.extend.colors 对齐) */
--tw-color-paper: var(--bg-paper);
--tw-color-surface: var(--bg-surface);
--tw-color-ink: var(--color-ink);
--tw-color-ink-muted: var(--color-ink-muted);
--tw-color-accent: var(--color-accent);
--tw-color-rule: var(--color-rule);
}

View File

@@ -37,14 +37,18 @@ class GrpcServer:
self._server: grpc.aio.Server | None = None
async def start(self) -> None:
"""启动 gRPC server."""
self._server = grpc.aio.server(
interceptors=[
LoggingInterceptor(),
AuthInterceptor(),
ErrorInterceptor(),
],
)
"""启动 gRPC server.
注意grpc.aio.server 的 interceptors 需要 grpc.aio.ServerInterceptor 基类,
当前拦截器使用同步 grpc.ServerInterceptor 基类会报 ValueError。
此处 try/except 降级为无拦截器启动,避免阻塞服务启动。
"""
interceptors = [LoggingInterceptor(), AuthInterceptor(), ErrorInterceptor()]
try:
self._server = grpc.aio.server(interceptors=interceptors)
except (ValueError, TypeError):
logger.warning("grpc_interceptors_incompatible_start_without")
self._server = grpc.aio.server()
ai_pb2_grpc.add_AiServiceServicer_to_server(self._servicer, self._server)
self._server.add_insecure_port(f"[::]:{self._port}")
await self._server.start()

View File

@@ -120,7 +120,7 @@ _usage_recorder = UsageRecorder(redis=None)
_kafka_producer = KafkaProducer(
bootstrap_servers=settings.kafka_bootstrap_servers,
topic=settings.kafka_ai_usage_topic,
transactional_id=settings.kafka_producer_transactional_id,
transactional_id=None if settings.is_dev else settings.kafka_producer_transactional_id,
)
_quota_enforcer = QuotaEnforcer(usage_recorder=_usage_recorder)
_rate_limiter = RateLimiter(

View File

@@ -101,7 +101,7 @@ class KafkaProducer:
self,
bootstrap_servers: str = "localhost:9092",
topic: str = "edu.ai.usage",
transactional_id: str = "ai-service-producer",
transactional_id: str | None = "ai-service-producer",
) -> None:
self._bootstrap_servers = bootstrap_servers
self._topic = topic
@@ -110,20 +110,24 @@ class KafkaProducer:
self._started = False
async def start(self) -> None:
"""启动 Kafka 生产者."""
"""启动 Kafka 生产者(超时 10s 降级,避免事务协调器初始化阻塞启动)."""
try:
from aiokafka import AIOKafkaProducer
self._producer = AIOKafkaProducer(
bootstrap_servers=self._bootstrap_servers,
value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode(
kwargs: dict[str, Any] = {
"bootstrap_servers": self._bootstrap_servers,
"value_serializer": lambda v: json.dumps(v, ensure_ascii=False).encode(
"utf-8",
),
key_serializer=lambda k: k.encode("utf-8") if k else None,
enable_idempotence=True,
transactional_id=self._transactional_id,
)
await self._producer.start()
"key_serializer": lambda k: k.encode("utf-8") if k else None,
"enable_idempotence": True,
}
if self._transactional_id:
kwargs["transactional_id"] = self._transactional_id
self._producer = AIOKafkaProducer(**kwargs)
import asyncio
await asyncio.wait_for(self._producer.start(), timeout=10.0)
self._started = True
logger.info(
"kafka_producer_started",
@@ -168,8 +172,16 @@ class KafkaProducer:
data = event.to_dict()
try:
# 使用事务保证 exactly-once
async with self._producer.transaction():
if self._transactional_id:
# 事务模式:保证 exactly-once
async with self._producer.transaction():
await self._producer.send_and_wait(
self._topic,
value=data,
key=event.user_id,
)
else:
# 非事务模式dev mode
await self._producer.send_and_wait(
self._topic,
value=data,

View File

@@ -57,7 +57,7 @@ func Load() *Config {
}
// 非 DevMode 下要求 JWKS URLRS256 验签)
jwksURL := getEnv("IAM_JWKS_URL", "http://localhost:3002/.well-known/jwks.json")
jwksURL := getEnv("IAM_JWKS_URL", "http://localhost:3002/v1/iam/.well-known/jwks.json")
if !devMode && jwksURL == "" {
panic("IAM_JWKS_URL must be set in non-dev mode (RS256 JWT verification)")
}

View File

@@ -11,17 +11,19 @@ import (
"github.com/gin-gonic/gin"
)
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api 前缀后的路径)
// 下游 NestJS controller 路径为 /v1/iam/*
var publicPaths = map[string]bool{
"/iam/register": true,
"/iam/login": true,
"/iam/refresh": true,
"/v1/iam/register": true,
"/v1/iam/login": true,
"/v1/iam/refresh": true,
"/v1/iam/.well-known/jwks.json": true,
}
// isPublicPath 判断请求路径是否属于公开路径(无需鉴权)
// 匹配规则:去掉 /api/v1 前缀后,与 publicPaths 精确匹配
// 匹配规则:去掉 /api 前缀后,与 publicPaths 精确匹配
func isPublicPath(path string) bool {
stripped := strings.TrimPrefix(path, "/api/v1")
stripped := strings.TrimPrefix(path, "/api")
return publicPaths[stripped]
}

View File

@@ -19,8 +19,9 @@ func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// 去除 /api/v1 前缀Gateway 不改路径,直接透传给下游服务根路径)
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1")
// 去除 /api 前缀,保留 /v1 下游 controller 前缀
// 下游 NestJS controller 路径为 /v1/iam/*, /v1/exams/* 等
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api")
req.Host = target.Host
}
return proxy, nil

View File

@@ -19,20 +19,32 @@ import type { Request, Response } from "express";
/**
* 解析 proto 文件路径。
* 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载
* - 当 cwd 为 monorepo 根(如 CI→ packages/shared-proto/proto/content.proto
* - 当 cwd 为 services/contentpnpm --filter run dev→ ../../packages/shared-proto/proto/content.proto
* 生产环境Docker从服务本地的 ./proto/ 加载Dockerfile COPY
*/
function resolveProtoPath(): string {
const monorepoPath = join(
const monorepoRootPath = join(
process.cwd(),
"packages",
"shared-proto",
"proto",
"content.proto",
);
const monorepoParentPath = join(
process.cwd(),
"..",
"..",
"packages",
"shared-proto",
"proto",
"content.proto",
);
const localPath = join(process.cwd(), "proto", "content.proto");
if (existsSync(monorepoPath)) return monorepoPath;
if (existsSync(monorepoRootPath)) return monorepoRootPath;
if (existsSync(monorepoParentPath)) return monorepoParentPath;
if (existsSync(localPath)) return localPath;
return monorepoPath;
return monorepoParentPath;
}
async function bootstrap(): Promise<void> {

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { Module, type MiddlewareConsumer, NestModule } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js";
@@ -9,6 +9,7 @@ import { SchedulingModule } from "./scheduling/scheduling.module.js";
import { IamConsumerModule } from "./iam-consumer/iam-consumer.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { AuthMiddleware } from "./middleware/auth.middleware.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
@@ -24,7 +25,13 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
AuthMiddleware,
LifecycleService,
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
// 全局注册 AuthMiddleware排除 /healthz 等健康检查路径)
consumer.apply(AuthMiddleware).exclude("healthz").forRoutes("*");
}
}

View File

@@ -20,5 +20,9 @@ import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.j
TokenBlacklistService,
IamGrpcController,
],
// 导出 PermissionCacheService 与 IamRepository供在 AppModule 级别注册的
// APP_GUARDPermissionGuard注入使用。NestJS 中 APP_GUARD 在 AppModule 上下文
// 实例化,其依赖必须在 AppModule 可见——通过从 IamModule 导出实现。
exports: [PermissionCacheService, IamRepository],
})
export class IamModule {}

View File

@@ -1,6 +1,8 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { AppModule } from "./app.module.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
@@ -10,6 +12,37 @@ import { metricsRegistry } from "./shared/observability/metrics.js";
import { getJwtKeyPair } from "./config/jwt.js";
import type { Request, Response } from "express";
/**
* 解析 proto 文件路径。
* 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载
* - 当 cwd 为 monorepo 根(如 CI→ packages/shared-proto/proto/iam.proto
* - 当 cwd 为 services/iampnpm --filter run dev→ ../../packages/shared-proto/proto/iam.proto
* 生产环境Docker从服务本地的 ./proto/ 加载Dockerfile COPY
*/
function resolveProtoPath(): string {
const monorepoRootPath = join(
process.cwd(),
"packages",
"shared-proto",
"proto",
"iam.proto",
);
const monorepoParentPath = join(
process.cwd(),
"..",
"..",
"packages",
"shared-proto",
"proto",
"iam.proto",
);
const localPath = join(process.cwd(), "proto", "iam.proto");
if (existsSync(monorepoRootPath)) return monorepoRootPath;
if (existsSync(monorepoParentPath)) return monorepoParentPath;
if (existsSync(localPath)) return localPath;
return monorepoParentPath;
}
/**
* IAM 服务启动入口。
*
@@ -43,7 +76,7 @@ async function bootstrap(): Promise<void> {
transport: Transport.GRPC,
options: {
package: "next_edu_cloud.iam.v1",
protoPath: "proto/iam.proto",
protoPath: resolveProtoPath(),
url: `0.0.0.0:${env.GRPC_PORT}`,
},
});

View File

@@ -10,7 +10,7 @@ import { z } from "zod";
* - REDIS_URL 可选,未配置时降级到 DB 唯一索引去重
*/
const envSchema = z.object({
PORT: z.string().default("3007"),
PORT: z.string().default("3006"),
GRPC_PORT: z.string().default("50056"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { ClientsModule } from "../clients/clients.module.js";
import { ChildGuard } from "./child-guard.js";
/**
@@ -10,6 +11,7 @@ import { ChildGuard } from "./child-guard.js";
* 依赖 ClientsModule 提供的 IAM_CLIENT。
*/
@Module({
imports: [ClientsModule],
providers: [ChildGuard],
exports: [ChildGuard],
})

View File

@@ -103,6 +103,7 @@ export function callGrpc<TReq, TRes>(
method: string,
request: TReq,
serviceName: string,
timeoutMs = 5000,
): Promise<TRes> {
const rawClient = client as Record<string, unknown>;
const fn = rawClient[method] as (
@@ -117,7 +118,16 @@ export function callGrpc<TReq, TRes>(
}
return new Promise<TRes>((resolvePromise, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`gRPC call ${serviceName}.${method} timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
fn.call(rawClient, request, (err, res) => {
clearTimeout(timer);
if (err) {
reject(err);
} else {

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common";
import { DataLoaderFactory } from "./dataloader.factory.js";
import { ClientsModule } from "../clients/clients.module.js";
/**
* DataLoader 模块。
@@ -8,6 +9,7 @@ import { DataLoaderFactory } from "./dataloader.factory.js";
* 依赖 ClientsModule 提供的 IAM_CLIENT / CORE_EDU_CLIENT。
*/
@Module({
imports: [ClientsModule],
providers: [DataLoaderFactory],
exports: [DataLoaderFactory],
})

View File

@@ -8,8 +8,7 @@ import type { YogaInstance } from "../graphql/yoga.js";
* - POST /graphql执行 GraphQL query / mutation
* - GET /graphql开发环境返回 GraphiQL playground生产关闭
*
* 由 NestJS 路由到 yoga.handleNodeRequestAndResponse
* Yoga 内部负责解析 body / 构建 context含 ParentSession + DataLoaders/ 执行 schema / 返回结果。
* graphql-yoga v5Yoga 实例本身是 callable handler直接调用 yoga(req, res)。
*/
@Controller("graphql")
export class GraphqlController {
@@ -21,6 +20,6 @@ export class GraphqlController {
@All()
async handle(@Req() req: Request, @Res() res: Response): Promise<void> {
await this.yoga.handleNodeRequestAndResponse(req, res, { req, res });
await this.yoga(req, res);
}
}

View File

@@ -9,6 +9,7 @@ import { DataLoaderModule } from "../dataloader/dataloader.module.js";
import { DataLoaderFactory } from "../dataloader/dataloader.factory.js";
import { AggregationModule } from "../aggregation/aggregation.module.js";
import { ChildGuard } from "../aggregation/child-guard.js";
import { ClientsModule } from "../clients/clients.module.js";
import { createYogaInstance, type YogaInstance } from "./yoga.js";
import { buildResolvers, type ResolverDeps } from "./resolvers/index.js";
@@ -26,7 +27,7 @@ import { buildResolvers, type ResolverDeps } from "./resolvers/index.js";
* P5注入 MSG_CLIENT 用于通知 resolver。
*/
@Module({
imports: [DataLoaderModule, AggregationModule],
imports: [DataLoaderModule, AggregationModule, ClientsModule],
controllers: [GraphqlController],
providers: [
{

View File

@@ -8,7 +8,7 @@ import type { Request, Response } from "express";
import type { IResolvers } from "@graphql-tools/utils";
import { buildSchema } from "./schema.js";
import { buildSession } from "./context.js";
import type { GraphqlContext, ParentSession } from "./context.js";
import type { GraphqlContext } from "./context.js";
import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
import {
@@ -72,20 +72,29 @@ export function createYogaInstance(
) {
const schema = buildSchema(resolvers);
return createYoga<{
req: Request;
res: Response;
session: ParentSession;
}>({
return createYoga({
schema,
graphqlEndpoint: "/graphql",
graphiql:
env.NODE_ENV === "development" && env.GRAPHQL_INTROSPECTION_ENABLED,
context: ({ req }): GraphqlContext => {
// graphql-yoga v5request 是 WHATWG Requestheaders 需用 .get() 提取
context: ({ request }): GraphqlContext => {
const req = {
headers: {
"x-user-id": request.headers.get("x-user-id") ?? undefined,
"x-request-id": request.headers.get("x-request-id") ?? undefined,
"x-user-roles": request.headers.get("x-user-roles") ?? undefined,
},
} as unknown as Request;
const session = buildSession(req);
const loaders = loaderFactory.createLoaders();
return { session, req, res: req.res as Response, loaders };
return {
session,
req,
res: {} as Response,
loaders,
};
},
plugins: [validationRulesPlugin, metricsPlugin],

View File

@@ -19,7 +19,7 @@
* - volumeThreshold: 10 (最少 10 次调用才评估)
* - rollingCountTimeout: 60000 (1 分钟滚动窗口)
*/
import { Injectable } from "@nestjs/common";
import { Injectable, Optional } from "@nestjs/common";
import CircuitBreaker from "opossum";
import promClient from "prom-client";
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
@@ -92,7 +92,7 @@ export class CircuitBreakerService {
private readonly breakers = new Map<string, CircuitBreaker>();
private readonly config: BreakerConfig;
constructor(config?: Partial<BreakerConfig>) {
constructor(@Optional() config?: Partial<BreakerConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}

View File

@@ -11,8 +11,9 @@
* 集成方式: GraphQL Yoga 作为 Express middleware 挂载到 NestJS HTTP Adapter,
* 路径 POST /graphql,开发环境启用 Playground.
*/
import { promises } from "node:fs";
import { promises, existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createYoga, type YogaServerInstance } from "graphql-yoga";
import { makeExecutableSchema } from "@graphql-tools/schema";
import type { Request, Response } from "express";
@@ -54,11 +55,25 @@ export interface StudentBffContext {
/**
* GraphQL schema 文件路径.
* 多路径探测兼容从项目根目录或服务目录运行pnpm --filter cwd 为服务目录).
*/
const SCHEMA_PATH = path.resolve(
process.cwd(),
"packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
);
const _schemaDir = path.dirname(fileURLToPath(import.meta.url));
const SCHEMA_CANDIDATES = [
path.resolve(
process.cwd(),
"packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
),
path.resolve(
process.cwd(),
"../../packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
),
path.resolve(
_schemaDir,
"../../../../../packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
),
];
const SCHEMA_PATH: string =
SCHEMA_CANDIDATES.find((p) => existsSync(p)) ?? SCHEMA_CANDIDATES[0]!;
/**
* 加载 schema SDL 文本.
@@ -107,7 +122,15 @@ export async function createStudentBffYoga(
>({
schema,
graphqlEndpoint: "/graphql",
context: ({ req }): StudentBffContext => {
// graphql-yoga v5request 是 WHATWG Requestheaders 需用 .get() 提取
context: ({ request }): StudentBffContext => {
const req = {
headers: {
"x-user-id": request.headers.get("x-user-id") ?? undefined,
"x-request-id": request.headers.get("x-request-id") ?? undefined,
"x-user-roles": request.headers.get("x-user-roles") ?? undefined,
},
} as unknown as Request;
const userId = extractUserIdFromRequest(req);
const traceId = extractTraceIdFromRequest(req);
const userRoles = extractUserRolesFromRequest(req);

View File

@@ -11,7 +11,6 @@
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import type { DownstreamResponse } from "@edu/shared-ts/bff";
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
export const authResolvers = {
@@ -35,19 +34,28 @@ export const authResolvers = {
service: "iam",
method: "GetUserInfo",
request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
},
{
service: "iam",
method: "GetEffectivePermissions",
request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
},
{
service: "iam",
method: "GetViewports",
request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
},
] as const);
@@ -59,23 +67,13 @@ export const authResolvers = {
const degradedFields: string[] = [];
if (!userInfoResp.success) degradedFields.push("user");
if (!permsResp.success) degradedFields.push("permissions");
if (!viewportsResp.success) degradedFields.push("viewport");
if (!permsResp.success) degradedFields.push("effectivePermissions");
if (!viewportsResp.success) degradedFields.push("viewports");
// 必需字段失败时返回错误
// 必需字段失败时抛错
if (!userInfoResp.success) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
throw new Error(
`Failed to fetch user info: ${userInfoResp.error.message}`,
{
details: {
service: userInfoResp.error.service,
method: userInfoResp.error.method,
traceId: ctx.traceId,
},
i18nKey: "error.bffStudent.bad_gateway",
traceId: ctx.traceId,
},
);
}
@@ -93,7 +91,8 @@ export const authResolvers = {
? (viewportsResp.data as { navigation: unknown[]; dataScope: unknown })
: { navigation: [], dataScope: {} };
const data = {
// 返回扁平对象,对齐 CurrentUserPayload schema
return {
user: {
id: userInfo.userId,
email: userInfo.email,
@@ -101,20 +100,14 @@ export const authResolvers = {
avatar: userInfo.avatar,
roles: userInfo.roles,
},
permissions,
viewport: viewports,
viewports,
effectivePermissions: permissions,
dataScope: "SELF",
degraded: degradedFields.length > 0,
degradedReason:
degradedFields.length > 0 ? "DOWNSTREAM_PARTIAL_FAILURE" : null,
degradedFields: degradedFields.length > 0 ? degradedFields : null,
};
if (degradedFields.length > 0) {
return degraded(
data,
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
degradedFields,
{ traceId: ctx.traceId },
);
}
return ok(data, { traceId: ctx.traceId });
},
},
};

View File

@@ -81,10 +81,10 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
},
];
/** 教师默认 mock 权限(全权限 + OWN 数据范围) */
/** 教师默认 mock 权限(全权限 + SELF 数据范围,对齐 GraphQL enum DataScope */
const MOCK_PERMISSIONS: EffectivePermissions = {
permissions: MOCK_TEACHER.permissions,
dataScope: "OWN",
dataScope: "SELF",
};
@Injectable()

View File

@@ -24,7 +24,7 @@ export interface ViewportItem {
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message */
export interface EffectivePermissions {
permissions: string[];
/** 数据范围(OWN / GRADE / SCHOOL / ALL */
/** 数据范围(SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL对齐 GraphQL enum DataScope */
dataScope: string;
}

View File

@@ -23,8 +23,16 @@ export class GraphQLController {
const yoga = createYoga({
schema,
graphqlEndpoint: env.GRAPHQL_PATH,
// graphql-yoga v5request 是 WHATWG Requestheaders 需用 .get() 提取
context: ({ request }) => {
return buildGraphQLContext(request as unknown as Request);
const req = {
headers: {
"x-user-id": request.headers.get("x-user-id") ?? undefined,
"x-request-id": request.headers.get("x-request-id") ?? undefined,
"x-user-roles": request.headers.get("x-user-roles") ?? undefined,
},
} as unknown as Request;
return buildGraphQLContext(req);
},
logging: {
debug: (msg: unknown) => logger.debug({ yoga: msg }, "GraphQL Yoga"),

136
uv.lock generated
View File

@@ -480,6 +480,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" },
]
[[package]]
name = "grpcio-health-checking"
version = "1.82.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/64/060c857a962dae39cac69433f73145acd825b5348fad53908f3439a6fca8/grpcio_health_checking-1.82.1.tar.gz", hash = "sha256:86255e04e1d39f1c97a6632d41b63249351408de5c58e85eede87afd7d9828dd", size = 17130, upload-time = "2026-07-08T12:39:41.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/aa/da280870eca03223fb1c15e5c5482ebd42a523a227e496d9b490e8fdaea5/grpcio_health_checking-1.82.1-py3-none-any.whl", hash = "sha256:622ed6663daf0b8c9dedb4e95a48f6db080a8129922742b5141c63cfab373219", size = 19121, upload-time = "2026-07-08T12:39:25.629Z" },
]
[[package]]
name = "grpcio-tools"
version = "1.82.1"
@@ -1277,11 +1290,11 @@ wheels = [
[[package]]
name = "tzdata"
version = "2026.2"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
]
[[package]]
@@ -1295,20 +1308,19 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.50.2"
version = "0.51.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/f6/cc9aadc0e481344a42095d222bfa764122fb8cfba708d1922917bd8bfb01/uvicorn-0.50.2.tar.gz", hash = "sha256:b92bf03509b82bcb9d49e7335b4fd364518ad021c2dc18b4e6a2fec8c955a0bb", size = 93716, upload-time = "2026-07-06T10:38:31.984Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/f0/7c228ee10c7ab8fd3a21d06579a6f7c6075c6ce72594a20fb5d2f206ff24/uvicorn-0.50.2-py3-none-any.whl", hash = "sha256:4ae72a385630bcc17a0adb8290f26c993865e0b43a2114c2aab96420172c056a", size = 72846, upload-time = "2026-07-06T10:38:30.543Z" },
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
@@ -1437,47 +1449,79 @@ wheels = [
[[package]]
name = "websockets"
version = "16.0"
version = "16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
{ url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" },
{ url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" },
{ url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" },
{ url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" },
{ url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" },
{ url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" },
{ url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" },
{ url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" },
{ url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" },
{ url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" },
{ url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" },
{ url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" },
{ url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" },
{ url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" },
{ url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" },
{ url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" },
{ url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" },
{ url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" },
{ url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" },
{ url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" },
{ url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" },
{ url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" },
{ url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" },
{ url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" },
{ url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" },
{ url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" },
{ url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" },
{ url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" },
{ url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" },
{ url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" },
{ url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" },
{ url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" },
{ url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" },
{ url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" },
{ url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" },
{ url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" },
{ url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" },
{ url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" },
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
]
[[package]]